diff --git a/frontend/src/components/VaultPerformanceChart.tsx b/frontend/src/components/VaultPerformanceChart.tsx
index 4628fa56..6066f069 100644
--- a/frontend/src/components/VaultPerformanceChart.tsx
+++ b/frontend/src/components/VaultPerformanceChart.tsx
@@ -16,6 +16,7 @@ import Skeleton, { ChartSkeleton } from "./Skeleton";
import { type TimeRange, getNow, getCutoffDate } from "../lib/dateUtils";
import { usePreferencesContext } from "../context/PreferencesContext";
import { formatDate, formatNumber } from "../lib/formatters";
+import { formatChartNumber, createChartNumberTickFormatter } from "../lib/chartFormatters";
import RefreshControl from "./RefreshControl";
import { useQueryWithPolling, POLLING_INTERVALS } from "../hooks/useQueryWithPolling";
import { useStaleIndicator } from "../hooks/useStaleIndicator";
@@ -44,7 +45,7 @@ const VaultPerformanceTooltip = ({
{label ? formatDate(label, { month: "short", day: "numeric", year: "numeric" }, locale) : ""}
- Index: {formatNumber(value, { locale, minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ Index: {formatChartNumber(value, locale, { maxDecimals: 2 })}
);
@@ -177,7 +178,7 @@ const VaultPerformanceChart: React.FC = () => {
axisLine={false}
tickLine={false}
tick={{ fill: "var(--text-secondary)", fontSize: 11 }}
- tickFormatter={(value: number) => formatNumber(value, { locale, maximumFractionDigits: 2 })}
+ tickFormatter={createChartNumberTickFormatter(locale, true)}
/>
} />
{
axisLine={false}
tickLine={false}
tick={{ fill: "var(--text-secondary)", fontSize: 11 }}
- tickFormatter={(value: number) => formatNumber(value, { locale, maximumFractionDigits: 2 })}
+ tickFormatter={createChartNumberTickFormatter(locale, true)}
/>
} />
- Daily yield: {formatCurrency(value, currency, 2, locale)}
+ Daily yield: {formatChartCurrency(value, currency, locale, { maxDecimals: 2 })}
);
@@ -219,7 +220,7 @@ const YieldBreakdownChart: React.FC = ({ totalGain })
axisLine={false}
tickLine={false}
tick={{ fill: "var(--text-secondary)", fontSize: 11 }}
- tickFormatter={(value: number) => formatCurrency(value, currency, 2, locale)}
+ tickFormatter={createChartCurrencyTickFormatter(currency, locale, true)}
/>
} />
{
+ const locale = "en-US";
+
+ describe("formatChartNumber", () => {
+ it("formats numbers with default settings", () => {
+ expect(formatChartNumber(1234, locale)).toBe("1,234");
+ expect(formatChartNumber(1234.567, locale)).toBe("1,234.57");
+ });
+
+ it("uses compact notation for large numbers", () => {
+ const formatted = formatChartNumber(1_500_000, locale);
+ expect(formatted).toContain("M"); // Should use compact notation like "1.5M"
+ });
+
+ it("respects custom max decimals", () => {
+ const formatted = formatChartNumber(1234.567, locale, { maxDecimals: 0 });
+ expect(formatted).toBe("1,234");
+ });
+
+ it("respects custom compact threshold", () => {
+ // With default threshold (1M), 500k should not be compacted
+ const formatted = formatChartNumber(500_000, locale, { compactThreshold: 1_000_000 });
+ expect(formatted).not.toContain("M");
+ });
+ });
+
+ describe("formatChartCurrency", () => {
+ it("formats currency values with default settings", () => {
+ const formatted = formatChartCurrency(1234.56, "USD", locale);
+ expect(formatted).toContain("1,234.56");
+ expect(formatted).toContain("$");
+ });
+
+ it("uses compact notation for large currency values", () => {
+ const formatted = formatChartCurrency(1_500_000, "USD", locale);
+ expect(formatted).toContain("M"); // Should use compact notation like "$1.5M"
+ });
+
+ it("respects custom max decimals", () => {
+ const formatted = formatChartCurrency(1234.567, "USD", locale, { maxDecimals: 0 });
+ expect(formatted).not.toContain(".567");
+ });
+
+ it("formats zero value correctly", () => {
+ const formatted = formatChartCurrency(0, "USD", locale);
+ expect(formatted).toContain("0");
+ expect(formatted).toContain("$");
+ });
+ });
+
+ describe("formatChartPercent", () => {
+ it("formats percentage values with default settings (0-100 range)", () => {
+ const formatted = formatChartPercent(50, locale);
+ expect(formatted).toContain("50");
+ expect(formatted).toContain("%");
+ });
+
+ it("formats decimal percentage values (0-1 range)", () => {
+ const formatted = formatChartPercent(0.5, locale, { isDecimal: true });
+ expect(formatted).toContain("50");
+ expect(formatted).toContain("%");
+ });
+
+ it("respects custom max decimals", () => {
+ const formatted = formatChartPercent(33.333, locale, { maxDecimals: 0 });
+ expect(formatted).toContain("33");
+ });
+ });
+
+ describe("formatChartAxisNumber", () => {
+ it("formats axis numbers with minimal decimals", () => {
+ const formatted = formatChartAxisNumber(1234.567, locale);
+ expect(formatted).toBe("1,234");
+ });
+
+ it("uses more aggressive compacting for axis labels", () => {
+ const formatted = formatChartAxisNumber(500_000, locale);
+ expect(formatted).toContain("K"); // Should use compact notation like "500K"
+ });
+ });
+
+ describe("formatChartAxisCurrency", () => {
+ it("formats axis currency with aggressive compacting", () => {
+ const formatted = formatChartAxisCurrency(500_000, "USD", locale);
+ expect(formatted).toContain("K"); // Should use compact notation like "$500K"
+ });
+
+ it("uses minimal decimals for axis labels", () => {
+ const formatted = formatChartAxisCurrency(1234.56, "USD", locale);
+ expect(formatted).not.toContain("56"); // Should not show decimals
+ });
+ });
+
+ describe("formatChartAxisPercent", () => {
+ it("formats axis percentages with no decimals", () => {
+ const formatted = formatChartAxisPercent(50.5, locale);
+ expect(formatted).toContain("50");
+ expect(formatted).not.toContain(".5");
+ expect(formatted).toContain("%");
+ });
+
+ it("handles decimal percentages correctly", () => {
+ const formatted = formatChartAxisPercent(0.505, locale, true);
+ expect(formatted).toContain("50");
+ expect(formatted).toContain("%");
+ });
+ });
+
+ describe("createChartCurrencyTickFormatter", () => {
+ it("returns a function that formats currency for axis labels", () => {
+ const formatter = createChartCurrencyTickFormatter("USD", locale, true);
+ expect(typeof formatter).toBe("function");
+
+ const formatted = formatter(500_000);
+ expect(formatted).toContain("K");
+ });
+
+ it("uses standard formatting when isAxisLabel is false", () => {
+ const formatter = createChartCurrencyTickFormatter("USD", locale, false);
+ const formatted = formatter(1234.56);
+ expect(formatted).toContain("1,234");
+ });
+ });
+
+ describe("createChartPercentTickFormatter", () => {
+ it("returns a function that formats percentages", () => {
+ const formatter = createChartPercentTickFormatter(locale, false, true);
+ expect(typeof formatter).toBe("function");
+
+ const formatted = formatter(50);
+ expect(formatted).toContain("50");
+ expect(formatted).toContain("%");
+ });
+
+ it("handles decimal mode correctly", () => {
+ const formatter = createChartPercentTickFormatter(locale, true, true);
+ const formatted = formatter(0.5);
+ expect(formatted).toContain("50");
+ expect(formatted).toContain("%");
+ });
+
+ it("uses more decimals when not axis label", () => {
+ const formatterAxis = createChartPercentTickFormatter(locale, false, true);
+ const formatterTooltip = createChartPercentTickFormatter(locale, false, false);
+
+ // Axis labels should have no decimals
+ const axisFormatted = formatterAxis(33.333);
+ expect(axisFormatted).toContain("33%");
+
+ // Tooltip values should have more decimals
+ const tooltipFormatted = formatterTooltip(33.333);
+ expect(tooltipFormatted).toContain("33");
+ });
+ });
+
+ describe("createChartNumberTickFormatter", () => {
+ it("returns a function that formats numbers", () => {
+ const formatter = createChartNumberTickFormatter(locale, true);
+ expect(typeof formatter).toBe("function");
+
+ const formatted = formatter(1234.567);
+ expect(formatted).toContain("1,234");
+ });
+
+ it("uses aggressive compacting for axis labels", () => {
+ const formatter = createChartNumberTickFormatter(locale, true);
+ const formatted = formatter(500_000);
+ expect(formatted).toContain("K");
+ });
+
+ it("uses standard compacting for non-axis labels", () => {
+ const formatter = createChartNumberTickFormatter(locale, false);
+ const formatted = formatter(500_000);
+ expect(formatted).not.toContain("K");
+ });
+ });
+
+ describe("locale support", () => {
+ it("formats currency correctly for different locales", () => {
+ const deFormatted = formatChartCurrency(1234.56, "EUR", "de-DE");
+ const enFormatted = formatChartCurrency(1234.56, "EUR", "en-US");
+
+ // Both should contain the number and EUR symbol, but may be formatted differently
+ expect(deFormatted).toContain("1.234");
+ expect(enFormatted).toContain("1,234");
+ });
+
+ it("formats numbers with locale-specific separators", () => {
+ const deFormatted = formatChartNumber(1234.56, "de-DE");
+ const enFormatted = formatChartNumber(1234.56, "en-US");
+
+ // German uses comma for decimal, period for thousands
+ expect(deFormatted).toContain("1.234");
+ // English uses period for decimal, comma for thousands
+ expect(enFormatted).toContain("1,234");
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles zero values", () => {
+ expect(formatChartNumber(0, locale)).toBe("0");
+ expect(formatChartCurrency(0, "USD", locale)).toContain("$0");
+ expect(formatChartPercent(0, locale)).toContain("0%");
+ });
+
+ it("handles negative values", () => {
+ expect(formatChartNumber(-1234, locale)).toContain("-");
+ expect(formatChartCurrency(-1234, "USD", locale)).toContain("-");
+ expect(formatChartPercent(-50, locale)).toContain("-");
+ });
+
+ it("handles very large numbers", () => {
+ const formatted = formatChartNumber(1_000_000_000, locale);
+ expect(formatted).toContain("B"); // Should use "B" for billions
+ });
+
+ it("handles very small numbers", () => {
+ const formatted = formatChartNumber(0.001, locale);
+ expect(formatted).toBe("0");
+ });
+ });
+});
diff --git a/frontend/src/lib/chartFormatters.ts b/frontend/src/lib/chartFormatters.ts
new file mode 100644
index 00000000..88fcaa52
--- /dev/null
+++ b/frontend/src/lib/chartFormatters.ts
@@ -0,0 +1,222 @@
+/**
+ * Chart-specific formatting utilities for tooltips and axes.
+ * Provides locale-aware formatting with sensible defaults for chart contexts.
+ * Ensures consistency between chart values and tabular displays.
+ */
+
+import {
+ formatNumber,
+ formatCurrency,
+ formatPercent,
+ type NumberFormatOptions,
+ type CurrencyFormatOptions,
+ type PercentFormatOptions,
+} from "./formatters";
+
+/**
+ * Format a currency value for chart display (tooltips/axes).
+ * Uses compact notation for very large numbers.
+ * @param value - The numeric value to format
+ * @param currency - Currency code (e.g., "USD")
+ * @param locale - Locale code for formatting
+ * @param options - Additional formatting options
+ */
+export function formatChartCurrency(
+ value: number,
+ currency: string,
+ locale: string,
+ options?: {
+ compactThreshold?: number; // Use compact notation above this value (default: 1M)
+ maxDecimals?: number; // Max decimal places (default: 2)
+ }
+): string {
+ const compactThreshold = options?.compactThreshold ?? 1_000_000;
+ const maxDecimals = options?.maxDecimals ?? 2;
+
+ // Use compact notation for very large numbers
+ if (Math.abs(value) >= compactThreshold) {
+ const formatter = new Intl.NumberFormat(locale, {
+ notation: "compact",
+ compactDisplay: "short",
+ style: "currency",
+ currency: currency,
+ maximumFractionDigits: Math.min(maxDecimals, 1),
+ });
+ return formatter.format(value);
+ }
+
+ // Use standard currency formatting for smaller numbers
+ return formatCurrency(value, {
+ currency,
+ locale,
+ minimumFractionDigits: value === 0 || value % 1 !== 0 ? maxDecimals : 0,
+ maximumFractionDigits: maxDecimals,
+ });
+}
+
+/**
+ * Format a percentage value for chart display (tooltips/axes).
+ * @param value - The numeric value to format (0-1 range if isDecimal=true, 0-100 if false)
+ * @param locale - Locale code for formatting
+ * @param options - Additional formatting options
+ */
+export function formatChartPercent(
+ value: number,
+ locale: string,
+ options?: {
+ isDecimal?: boolean; // Value is in 0-1 range (default: false, assumes 0-100)
+ maxDecimals?: number; // Max decimal places (default: 2)
+ }
+): string {
+ const isDecimal = options?.isDecimal ?? false;
+ const maxDecimals = options?.maxDecimals ?? 2;
+
+ return formatPercent(value, {
+ isDecimal,
+ locale,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: maxDecimals,
+ });
+}
+
+/**
+ * Format a number for chart display (tooltips/axes).
+ * @param value - The numeric value to format
+ * @param locale - Locale code for formatting
+ * @param options - Additional formatting options
+ */
+export function formatChartNumber(
+ value: number,
+ locale: string,
+ options?: {
+ compactThreshold?: number; // Use compact notation above this value (default: 1M)
+ maxDecimals?: number; // Max decimal places (default: 2)
+ }
+): string {
+ const compactThreshold = options?.compactThreshold ?? 1_000_000;
+ const maxDecimals = options?.maxDecimals ?? 2;
+
+ // Use compact notation for very large numbers
+ if (Math.abs(value) >= compactThreshold) {
+ const formatter = new Intl.NumberFormat(locale, {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: Math.min(maxDecimals, 1),
+ });
+ return formatter.format(value);
+ }
+
+ // Use standard formatting for smaller numbers
+ return formatNumber(value, {
+ locale,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: maxDecimals,
+ });
+}
+
+/**
+ * Format a currency value for chart axis labels.
+ * Uses more aggressive compact notation and fewer decimals for readability.
+ * @param value - The numeric value to format
+ * @param currency - Currency code (e.g., "USD")
+ * @param locale - Locale code for formatting
+ */
+export function formatChartAxisCurrency(
+ value: number,
+ currency: string,
+ locale: string
+): string {
+ return formatChartCurrency(value, currency, locale, {
+ compactThreshold: 100_000, // More aggressive compacting for axis labels
+ maxDecimals: 1,
+ });
+}
+
+/**
+ * Format a percentage value for chart axis labels.
+ * Uses minimal decimals for readability.
+ * @param value - The numeric value to format (0-1 range if isDecimal=true, 0-100 if false)
+ * @param locale - Locale code for formatting
+ * @param isDecimal - Value is in 0-1 range (default: false)
+ */
+export function formatChartAxisPercent(
+ value: number,
+ locale: string,
+ isDecimal: boolean = false
+): string {
+ return formatChartPercent(value, locale, {
+ isDecimal,
+ maxDecimals: isDecimal ? 0 : 0, // No decimals for axis labels
+ });
+}
+
+/**
+ * Format a number value for chart axis labels.
+ * Uses minimal decimals for readability.
+ * @param value - The numeric value to format
+ * @param locale - Locale code for formatting
+ */
+export function formatChartAxisNumber(value: number, locale: string): string {
+ return formatChartNumber(value, locale, {
+ compactThreshold: 100_000, // More aggressive compacting for axis labels
+ maxDecimals: 0, // No decimals for axis labels
+ });
+}
+
+/**
+ * Create a callback for recharts tickFormatter that formats currency values.
+ * Returns a function suitable for YAxis/XAxis tickFormatter prop.
+ * @param currency - Currency code (e.g., "USD")
+ * @param locale - Locale code for formatting
+ * @param isAxisLabel - If true, uses aggressive compacting; if false, uses standard formatting
+ */
+export function createChartCurrencyTickFormatter(
+ currency: string,
+ locale: string,
+ isAxisLabel: boolean = true
+): (value: number) => string {
+ return (value: number) => {
+ if (isAxisLabel) {
+ return formatChartAxisCurrency(value, currency, locale);
+ }
+ return formatChartCurrency(value, currency, locale);
+ };
+}
+
+/**
+ * Create a callback for recharts tickFormatter that formats percentage values.
+ * Returns a function suitable for YAxis/XAxis tickFormatter prop.
+ * @param locale - Locale code for formatting
+ * @param isDecimal - Value is in 0-1 range (default: false)
+ * @param isAxisLabel - If true, uses minimal decimals; if false, uses standard formatting
+ */
+export function createChartPercentTickFormatter(
+ locale: string,
+ isDecimal: boolean = false,
+ isAxisLabel: boolean = true
+): (value: number) => string {
+ return (value: number) => {
+ if (isAxisLabel) {
+ return formatChartAxisPercent(value, locale, isDecimal);
+ }
+ return formatChartPercent(value, locale, { isDecimal, maxDecimals: 2 });
+ };
+}
+
+/**
+ * Create a callback for recharts tickFormatter that formats numeric values.
+ * Returns a function suitable for YAxis/XAxis tickFormatter prop.
+ * @param locale - Locale code for formatting
+ * @param isAxisLabel - If true, uses minimal decimals; if false, uses standard formatting
+ */
+export function createChartNumberTickFormatter(
+ locale: string,
+ isAxisLabel: boolean = true
+): (value: number) => string {
+ return (value: number) => {
+ if (isAxisLabel) {
+ return formatChartAxisNumber(value, locale);
+ }
+ return formatChartNumber(value, locale);
+ };
+}