diff --git a/src/components/common/PriceSparkline.tsx b/src/components/common/PriceSparkline.tsx new file mode 100644 index 00000000..bf67d7e3 --- /dev/null +++ b/src/components/common/PriceSparkline.tsx @@ -0,0 +1,83 @@ +import { cn } from '@/lib/utils'; + +interface PriceSparklineProps { + dataPoints: number[]; + width?: number; + height?: number; + className?: string; +} + +const POSITIVE_COLOR = '#34d399'; +const NEUTRAL_COLOR = 'currentColor'; + +export function PriceSparkline({ + dataPoints, + width = 120, + height = 40, + className, +}: PriceSparklineProps) { + if (dataPoints.length === 0) return null; + + const lineColor = + dataPoints.length >= 2 && dataPoints[dataPoints.length - 1] > dataPoints[0] + ? POSITIVE_COLOR + : NEUTRAL_COLOR; + + const padding = 2; + const innerWidth = width - padding * 2; + const innerHeight = height - padding * 2; + + if (dataPoints.length === 1) { + return ( + + + + ); + } + + const min = Math.min(...dataPoints); + const max = Math.max(...dataPoints); + const range = max - min || 1; + + const buildPathD = () => + dataPoints + .map((value, index) => { + const x = padding + (index / (dataPoints.length - 1)) * innerWidth; + const y = + padding + (1 - (value - min) / range) * innerHeight; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)} ${y.toFixed(1)}`; + }) + .join(' '); + + return ( + + + + ); +} diff --git a/src/components/common/__tests__/PriceSparkline.test.tsx b/src/components/common/__tests__/PriceSparkline.test.tsx new file mode 100644 index 00000000..1ebdbf40 --- /dev/null +++ b/src/components/common/__tests__/PriceSparkline.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PriceSparkline } from '../PriceSparkline'; + +describe('PriceSparkline', () => { + it('renders an SVG path for 7 data points', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + const path = container.querySelector('path'); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute('d'); + }); + + it('renders without error for 1 data point and does not produce a line path', () => { + const { container } = render( + + ); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + + const path = container.querySelector('path'); + expect(path).not.toBeInTheDocument(); + }); + + it('renders nothing for 0 data points', () => { + const { container } = render( + + ); + + expect(container.innerHTML).toBe(''); + }); + + it('applies green line colour when the last value is higher than the first', () => { + const { container } = render( + + ); + + const path = container.querySelector('path'); + expect(path).toHaveAttribute('stroke', '#34d399'); + }); +});