diff --git a/README.md b/README.md index 49376cc..b5e3fde 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ If empty, the app uses the local default (`/api/rates`). - Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`. - `GET /api/market?code=BTC` - Returns CoinGecko-based market snapshot for a crypto asset. - - Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `updatedAt`. + - Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistory24h`, `updatedAt`. ## Architecture Notes diff --git a/components/converter/converter-card.tsx b/components/converter/converter-card.tsx index 358da1a..73c0e4f 100644 --- a/components/converter/converter-card.tsx +++ b/components/converter/converter-card.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { CurrencyIcon } from "@/components/converter/currency-icon"; +import { PriceSparkline } from "@/components/converter/price-sparkline"; import { CurrencySelect } from "@/components/converter/currency-select"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -550,6 +551,12 @@ export function ConverterCard({
) : null} +diff --git a/components/converter/price-sparkline.tsx b/components/converter/price-sparkline.tsx new file mode 100644 index 0000000..1c08fc3 --- /dev/null +++ b/components/converter/price-sparkline.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useId, useMemo } from "react"; + +import { cn } from "@/lib/utils"; + +interface PriceSparklinePoint { + timestamp: string; + priceUsd: number; +} + +interface PriceSparklineProps { + points: PriceSparklinePoint[]; + isLoading?: boolean; + className?: string; +} + +export function PriceSparkline({ + points, + isLoading = false, + className, +}: PriceSparklineProps) { + const gradientId = useId(); + + const chart = useMemo(() => { + const sorted = [...points] + .filter((point) => Number.isFinite(point.priceUsd) && point.priceUsd > 0) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + if (sorted.length < 2) { + return null; + } + + const width = 320; + const height = 84; + const padding = 6; + + const prices = sorted.map((point) => point.priceUsd); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const range = Math.max(maxPrice - minPrice, maxPrice * 0.001, 0.00000001); + + const coordinates = sorted.map((point, index) => { + const x = + padding + (index / (sorted.length - 1)) * (width - padding * 2); + const y = + padding + + (1 - (point.priceUsd - minPrice) / range) * (height - padding * 2); + + return { x, y }; + }); + + const linePath = coordinates + .map((point, index) => + `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`, + ) + .join(" "); + + const firstPoint = coordinates[0]; + const lastPoint = coordinates[coordinates.length - 1]; + + const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} L ${firstPoint.x.toFixed(2)} ${(height - padding).toFixed(2)} Z`; + const isUptrend = prices[prices.length - 1] >= prices[0]; + + return { + width, + height, + linePath, + areaPath, + isUptrend, + }; + }, [points]); + + return ( +
+ Price (24h) +
+ + {chart ? ( +