From 4537ce8b9c71836bddad1f7db8fc8f6ce668b535 Mon Sep 17 00:00:00 2001 From: zvspany Date: Tue, 10 Mar 2026 15:12:48 +0100 Subject: [PATCH] feat(market): add 24h crypto sparkline chart with CoinGecko history and resilient fallback --- README.md | 2 +- components/converter/converter-card.tsx | 7 ++ components/converter/price-sparkline.tsx | 138 +++++++++++++++++++++++ lib/api/crypto.ts | 42 +++++++ lib/market.ts | 13 +++ lib/server/market-cache.ts | 17 ++- 6 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 components/converter/price-sparkline.tsx 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 ? ( +
+ + + + + + + + + + + +
+ ) : ( +
+ {isLoading ? "Loading 24h price history..." : "24h price history unavailable"} +
+ )} +
+ ); +} diff --git a/lib/api/crypto.ts b/lib/api/crypto.ts index 40eff39..0be79df 100644 --- a/lib/api/crypto.ts +++ b/lib/api/crypto.ts @@ -13,6 +13,11 @@ export interface CryptoMarketSnapshot { updatedAt: string; } +export interface CryptoPricePoint { + timestamp: string; + priceUsd: number; +} + const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; function buildCoinGeckoHeaders(): HeadersInit { @@ -91,6 +96,43 @@ export async function fetchCryptoMarketSnapshot( }; } +export async function fetchCryptoPriceHistory24h( + providerId: string, +): Promise { + const url = `${COINGECKO_BASE_URL}/coins/${encodeURIComponent( + providerId, + )}/market_chart?vs_currency=usd&days=1`; + + const response = await fetch(url, { + next: { revalidate: 60 }, + headers: buildCoinGeckoHeaders(), + }); + + if (!response.ok) { + throw new Error(`Unable to load crypto price history (${response.status})`); + } + + const payload = (await response.json()) as { + prices?: Array<[number, number]>; + }; + + const points = (payload.prices ?? []) + .filter( + (entry): entry is [number, number] => + Array.isArray(entry) && + entry.length >= 2 && + Number.isFinite(entry[0]) && + Number.isFinite(entry[1]) && + entry[1] > 0, + ) + .map(([timestamp, priceUsd]) => ({ + timestamp: new Date(timestamp).toISOString(), + priceUsd, + })); + + return points; +} + export async function fetchCryptoData(): Promise { const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter( (id): id is string => Boolean(id) diff --git a/lib/market.ts b/lib/market.ts index 1e5a578..fc9e4eb 100644 --- a/lib/market.ts +++ b/lib/market.ts @@ -7,6 +7,10 @@ export interface CryptoMarketResponse { change24hPct: number | null; marketCapUsd: number | null; volume24hUsd: number | null; + priceHistory24h: Array<{ + timestamp: string; + priceUsd: number; + }>; updatedAt: string; source: string; } @@ -18,6 +22,15 @@ const marketResponseSchema = z.object({ change24hPct: z.number().nullable(), marketCapUsd: z.number().nullable(), volume24hUsd: z.number().nullable(), + priceHistory24h: z + .array( + z.object({ + timestamp: z.string(), + priceUsd: z.number().positive(), + }), + ) + .optional() + .default([]), updatedAt: z.string(), source: z.string(), }); diff --git a/lib/server/market-cache.ts b/lib/server/market-cache.ts index dfbd787..651744d 100644 --- a/lib/server/market-cache.ts +++ b/lib/server/market-cache.ts @@ -1,4 +1,7 @@ -import { fetchCryptoMarketSnapshot } from "@/lib/api/crypto"; +import { + fetchCryptoMarketSnapshot, + fetchCryptoPriceHistory24h, +} from "@/lib/api/crypto"; export interface CachedCryptoMarketSnapshot { code: string; @@ -7,6 +10,10 @@ export interface CachedCryptoMarketSnapshot { change24hPct: number | null; marketCapUsd: number | null; volume24hUsd: number | null; + priceHistory24h: Array<{ + timestamp: string; + priceUsd: number; + }>; updatedAt: string; source: "CoinGecko"; } @@ -44,6 +51,13 @@ export async function getCryptoMarketWithCache(params: { const requestPromise = (async () => { const snapshot = await fetchCryptoMarketSnapshot(params.providerId); + let history: Array<{ timestamp: string; priceUsd: number }> = []; + + try { + history = await fetchCryptoPriceHistory24h(params.providerId); + } catch { + history = []; + } const normalized: CachedCryptoMarketSnapshot = { code: params.code, @@ -52,6 +66,7 @@ export async function getCryptoMarketWithCache(params: { change24hPct: snapshot.change24hPct, marketCapUsd: snapshot.marketCapUsd, volume24hUsd: snapshot.volume24hUsd, + priceHistory24h: history, updatedAt: snapshot.updatedAt, source: "CoinGecko", };