From d11555db392a701debd33429263511383de7e4eb Mon Sep 17 00:00:00 2001 From: Danitello123 Date: Tue, 2 Jun 2026 00:39:37 +0100 Subject: [PATCH] feat: Add chart tooltip and axis localization for currency/percent formats - Create chartFormatters.ts with locale-aware formatting utilities - Add formatChartNumber, formatChartCurrency, formatChartPercent functions - Implement axis-specific formatters with compact notation support - Create tick formatter callbacks for recharts integration - Update VaultPerformanceChart with improved number formatting - Update YieldBreakdownChart with improved currency formatting - Add comprehensive test suite for chart formatters - Ensure consistency between chart and tabular value formatting - Implement proper locale fallback rules - Support compact notation for large numbers in axis labels Implements issue #9: Frontend chart localization with currency and percent formats --- .../src/components/VaultPerformanceChart.tsx | 7 +- .../src/components/YieldBreakdownChart.tsx | 5 +- frontend/src/lib/chartFormatters.test.ts | 235 ++++++++++++++++++ frontend/src/lib/chartFormatters.ts | 222 +++++++++++++++++ 4 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/chartFormatters.test.ts create mode 100644 frontend/src/lib/chartFormatters.ts diff --git a/frontend/src/components/VaultPerformanceChart.tsx b/frontend/src/components/VaultPerformanceChart.tsx index db954dca..76428fb4 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"; const VaultPerformanceTooltip = ({ active, @@ -41,7 +42,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 })}
); @@ -138,7 +139,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 })}
); @@ -222,7 +223,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); + }; +}