diff --git a/README.md b/README.md index b5e3fde..b32a6d3 100644 --- a/README.md +++ b/README.md @@ -161,9 +161,10 @@ If empty, the app uses the local default (`/api/rates`). - `GET /api/convert?from=USD&to=BTC&amount=100` - Converts between any supported fiat/crypto pair using current normalized rates. - Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`. -- `GET /api/market?code=BTC` +- `GET /api/market?code=BTC&range=24h` - Returns CoinGecko-based market snapshot for a crypto asset. - - Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistory24h`, `updatedAt`. + - Supported ranges: `24h`, `7d`, `30d`, `1y`, `all`. + - Example response fields: `priceUsd`, `change24hPct`, `marketCapUsd`, `volume24hUsd`, `priceHistoryRange`, `priceHistory`, `updatedAt`. ## Architecture Notes diff --git a/app/api/market/route.ts b/app/api/market/route.ts index e70ef22..851c657 100644 --- a/app/api/market/route.ts +++ b/app/api/market/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { MARKET_CHART_RANGES } from "@/lib/market"; import { getRatesWithCache } from "@/lib/server/rates-cache"; import { getCryptoMarketWithCache, @@ -10,6 +11,7 @@ import { const querySchema = z.object({ code: z.string().trim().toUpperCase().min(1), + range: z.enum(MARKET_CHART_RANGES).default("24h"), }); export const revalidate = 60; @@ -18,6 +20,7 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const queryResult = querySchema.safeParse({ code: searchParams.get("code") ?? "", + range: searchParams.get("range") ?? undefined, }); if (!queryResult.success) { @@ -32,7 +35,7 @@ export async function GET(request: NextRequest) { ); } - const { code } = queryResult.data; + const { code, range } = queryResult.data; try { const rates = await getRatesWithCache(); @@ -56,6 +59,7 @@ export async function GET(request: NextRequest) { code: asset.code, name: asset.name, providerId: asset.providerId, + range, }); return NextResponse.json(market, { @@ -65,7 +69,7 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - const fallback = getLastCachedCryptoMarket(code); + const fallback = getLastCachedCryptoMarket(code, range); if (fallback) { return NextResponse.json(fallback, { diff --git a/components/converter/converter-card.tsx b/components/converter/converter-card.tsx index 40a5d66..c48aa7d 100644 --- a/components/converter/converter-card.tsx +++ b/components/converter/converter-card.tsx @@ -29,6 +29,10 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useCryptoMarket } from "@/hooks/use-crypto-market"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { getDisplaySymbol } from "@/lib/currency-display"; +import { + MARKET_CHART_RANGES, + type MarketChartRange, +} from "@/lib/market"; import { useMarketRates } from "@/hooks/use-market-rates"; import { formatAmount, @@ -48,6 +52,13 @@ const DEFAULT_TO = "EUR"; const QUICK_AMOUNTS = [10, 50, 100, 500, 1000] as const; const DEFAULT_MULTI_CONVERSION_CODES = ["USD", "EUR", "BTC", "ETH", "SOL"] as const; const MAX_MULTI_CONVERSIONS = 4; +const MARKET_RANGE_LABELS: Record = { + "24h": "24h", + "7d": "7d", + "30d": "30d", + "1y": "1y", + all: "all", +}; interface ConverterCardProps { forcedFromCode?: string; @@ -135,6 +146,7 @@ export function ConverterCard({ const [fromCode, setFromCode] = useState(DEFAULT_FROM); const [toCode, setToCode] = useState(DEFAULT_TO); const [isCopied, setIsCopied] = useState(false); + const [marketRange, setMarketRange] = useState("24h"); const debouncedAmount = useDebouncedValue(amountInput, 120); @@ -259,7 +271,7 @@ export function ConverterCard({ error: marketError, isLoading: isMarketLoading, refresh: refreshMarket, - } = useCryptoMarket(marketAsset?.code ?? null); + } = useCryptoMarket(marketAsset?.code ?? null, marketRange); const handleCopyConvertedValue = async () => { if (convertedValue === null || !toAsset) { @@ -295,6 +307,35 @@ export function ConverterCard({ return Number.isFinite(latest) ? new Date(latest).toISOString() : data.updatedAt; }, [data, marketData?.updatedAt]); + const marketRangeChangePct = useMemo(() => { + if (!marketData) { + return null; + } + + const points = marketData.priceHistory; + + if (points.length >= 2) { + const first = points[0]?.priceUsd; + const last = points[points.length - 1]?.priceUsd; + + if ( + typeof first === "number" && + typeof last === "number" && + Number.isFinite(first) && + Number.isFinite(last) && + first > 0 + ) { + return ((last - first) / first) * 100; + } + } + + if (marketRange === "24h") { + return marketData.change24hPct; + } + + return null; + }, [marketData, marketRange]); + const amountPrefix = getDisplaySymbol(fromAsset); const amountInputPaddingLeft = useMemo(() => { if (!amountPrefix) { @@ -627,6 +668,30 @@ export function ConverterCard({ +
+ {MARKET_CHART_RANGES.map((range) => { + const isActive = marketRange === range; + + return ( + + ); + })} +
+ {marketError && !marketData ? (

Unable to load market data right now. @@ -634,7 +699,8 @@ export function ConverterCard({ ) : null} @@ -650,23 +716,21 @@ export function ConverterCard({

- 24h + {MARKET_RANGE_LABELS[marketRange]}

0 + marketRangeChangePct !== null + ? marketRangeChangePct > 0 ? "text-emerald-300" - : marketData.change24hPct < 0 + : marketRangeChangePct < 0 ? "text-red-300" : "text-foreground" : "text-foreground", )} > - {marketData - ? formatSignedPercent(marketData.change24hPct) - : "-"} + {marketData ? formatSignedPercent(marketRangeChangePct) : "-"}

diff --git a/components/converter/price-sparkline.tsx b/components/converter/price-sparkline.tsx index 1c08fc3..6fbaa6c 100644 --- a/components/converter/price-sparkline.tsx +++ b/components/converter/price-sparkline.tsx @@ -11,12 +11,14 @@ interface PriceSparklinePoint { interface PriceSparklineProps { points: PriceSparklinePoint[]; + rangeLabel: string; isLoading?: boolean; className?: string; } export function PriceSparkline({ points, + rangeLabel, isLoading = false, className, }: PriceSparklineProps) { @@ -82,7 +84,7 @@ export function PriceSparkline({ )} >

- Price (24h) + Price ({rangeLabel})

{chart ? ( @@ -91,7 +93,7 @@ export function PriceSparkline({ viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-20 w-full" role="img" - aria-label="Price trend over the last 24 hours" + aria-label={`Price trend over the last ${rangeLabel}`} preserveAspectRatio="none" shapeRendering="geometricPrecision" > @@ -130,7 +132,9 @@ export function PriceSparkline({
) : (
- {isLoading ? "Loading 24h price history..." : "24h price history unavailable"} + {isLoading + ? `Loading ${rangeLabel} price history...` + : `${rangeLabel} price history unavailable`}
)} diff --git a/hooks/use-crypto-market.ts b/hooks/use-crypto-market.ts index 1b98f30..c2a972f 100644 --- a/hooks/use-crypto-market.ts +++ b/hooks/use-crypto-market.ts @@ -4,13 +4,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { buildApiUrl } from "@/lib/api/url"; import { + type MarketChartRange, parseCryptoMarketResponse, type CryptoMarketResponse, } from "@/lib/market"; const REFRESH_INTERVAL_MS = 60_000; -export function useCryptoMarket(assetCode: string | null) { +export function useCryptoMarket( + assetCode: string | null, + range: MarketChartRange = "24h", +) { const [data, setData] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -18,7 +22,7 @@ export function useCryptoMarket(assetCode: string | null) { useEffect(() => { setData(null); setError(null); - }, [assetCode]); + }, [assetCode, range]); const fetchMarket = useCallback(async () => { if (!assetCode) { @@ -32,7 +36,9 @@ export function useCryptoMarket(assetCode: string | null) { try { const response = await fetch( - buildApiUrl(`/api/market?code=${encodeURIComponent(assetCode)}`), + buildApiUrl( + `/api/market?code=${encodeURIComponent(assetCode)}&range=${encodeURIComponent(range)}`, + ), ); if (!response.ok) { @@ -53,7 +59,7 @@ export function useCryptoMarket(assetCode: string | null) { } finally { setIsLoading(false); } - }, [assetCode]); + }, [assetCode, range]); useEffect(() => { void fetchMarket(); @@ -80,7 +86,7 @@ export function useCryptoMarket(assetCode: string | null) { window.clearInterval(id); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [assetCode, fetchMarket]); + }, [assetCode, range, fetchMarket]); return useMemo( () => ({ diff --git a/lib/api/crypto.ts b/lib/api/crypto.ts index 0be79df..447a8ad 100644 --- a/lib/api/crypto.ts +++ b/lib/api/crypto.ts @@ -1,4 +1,5 @@ import { CRYPTO_ASSETS } from "@/lib/assets"; +import type { MarketChartRange } from "@/lib/market"; export interface CryptoRateResult { usdPrices: Record; @@ -19,6 +20,13 @@ export interface CryptoPricePoint { } const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; +const COINGECKO_RANGE_TO_DAYS: Record = { + "24h": "1", + "7d": "7", + "30d": "30", + "1y": "365", + all: "max", +}; function buildCoinGeckoHeaders(): HeadersInit { const headers: Record = { @@ -96,12 +104,14 @@ export async function fetchCryptoMarketSnapshot( }; } -export async function fetchCryptoPriceHistory24h( +export async function fetchCryptoPriceHistory( providerId: string, + range: MarketChartRange, ): Promise { + const days = COINGECKO_RANGE_TO_DAYS[range]; const url = `${COINGECKO_BASE_URL}/coins/${encodeURIComponent( providerId, - )}/market_chart?vs_currency=usd&days=1`; + )}/market_chart?vs_currency=usd&days=${encodeURIComponent(days)}`; const response = await fetch(url, { next: { revalidate: 60 }, diff --git a/lib/market.ts b/lib/market.ts index fc9e4eb..b58bc61 100644 --- a/lib/market.ts +++ b/lib/market.ts @@ -1,5 +1,8 @@ import { z } from "zod"; +export const MARKET_CHART_RANGES = ["24h", "7d", "30d", "1y", "all"] as const; +export type MarketChartRange = (typeof MARKET_CHART_RANGES)[number]; + export interface CryptoMarketResponse { code: string; name: string; @@ -7,7 +10,8 @@ export interface CryptoMarketResponse { change24hPct: number | null; marketCapUsd: number | null; volume24hUsd: number | null; - priceHistory24h: Array<{ + priceHistoryRange: MarketChartRange; + priceHistory: Array<{ timestamp: string; priceUsd: number; }>; @@ -15,26 +19,38 @@ export interface CryptoMarketResponse { source: string; } -const marketResponseSchema = z.object({ - code: z.string(), - name: z.string(), +const pointSchema = z.object({ + timestamp: z.string(), priceUsd: z.number().positive(), - 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(), }); +const marketResponseSchema = z + .object({ + code: z.string(), + name: z.string(), + priceUsd: z.number().positive(), + change24hPct: z.number().nullable(), + marketCapUsd: z.number().nullable(), + volume24hUsd: z.number().nullable(), + priceHistoryRange: z.enum(MARKET_CHART_RANGES), + priceHistory: z.array(pointSchema).optional(), + priceHistory24h: z.array(pointSchema).optional(), + updatedAt: z.string(), + source: z.string(), + }) + .transform((value) => ({ + code: value.code, + name: value.name, + priceUsd: value.priceUsd, + change24hPct: value.change24hPct, + marketCapUsd: value.marketCapUsd, + volume24hUsd: value.volume24hUsd, + priceHistoryRange: value.priceHistoryRange, + priceHistory: value.priceHistory ?? value.priceHistory24h ?? [], + updatedAt: value.updatedAt, + source: value.source, + })); + export function parseCryptoMarketResponse( payload: unknown, ): CryptoMarketResponse { diff --git a/lib/server/market-cache.ts b/lib/server/market-cache.ts index 651744d..89b79f4 100644 --- a/lib/server/market-cache.ts +++ b/lib/server/market-cache.ts @@ -1,7 +1,8 @@ import { fetchCryptoMarketSnapshot, - fetchCryptoPriceHistory24h, + fetchCryptoPriceHistory, } from "@/lib/api/crypto"; +import type { MarketChartRange } from "@/lib/market"; export interface CachedCryptoMarketSnapshot { code: string; @@ -10,7 +11,8 @@ export interface CachedCryptoMarketSnapshot { change24hPct: number | null; marketCapUsd: number | null; volume24hUsd: number | null; - priceHistory24h: Array<{ + priceHistoryRange: MarketChartRange; + priceHistory: Array<{ timestamp: string; priceUsd: number; }>; @@ -30,12 +32,33 @@ export const MARKET_CACHE_CONTROL_VALUE = const marketCache = new Map(); const inFlightRequests = new Map>(); +function downsamplePriceHistory( + points: Array<{ timestamp: string; priceUsd: number }>, + maxPoints: number, +): Array<{ timestamp: string; priceUsd: number }> { + if (points.length <= maxPoints) { + return points; + } + + const sampled: Array<{ timestamp: string; priceUsd: number }> = []; + const lastIndex = points.length - 1; + const step = lastIndex / (maxPoints - 1); + + for (let index = 0; index < maxPoints; index += 1) { + const sourceIndex = Math.round(index * step); + sampled.push(points[Math.min(sourceIndex, lastIndex)]); + } + + return sampled; +} + export async function getCryptoMarketWithCache(params: { code: string; name: string; providerId: string; + range: MarketChartRange; }): Promise { - const cacheKey = params.code; + const cacheKey = `${params.code}:${params.range}`; const cached = marketCache.get(cacheKey); const now = Date.now(); @@ -54,7 +77,8 @@ export async function getCryptoMarketWithCache(params: { let history: Array<{ timestamp: string; priceUsd: number }> = []; try { - history = await fetchCryptoPriceHistory24h(params.providerId); + history = await fetchCryptoPriceHistory(params.providerId, params.range); + history = downsamplePriceHistory(history, 180); } catch { history = []; } @@ -66,7 +90,8 @@ export async function getCryptoMarketWithCache(params: { change24hPct: snapshot.change24hPct, marketCapUsd: snapshot.marketCapUsd, volume24hUsd: snapshot.volume24hUsd, - priceHistory24h: history, + priceHistoryRange: params.range, + priceHistory: history, updatedAt: snapshot.updatedAt, source: "CoinGecko", }; @@ -90,6 +115,7 @@ export async function getCryptoMarketWithCache(params: { export function getLastCachedCryptoMarket( code: string, + range: MarketChartRange, ): CachedCryptoMarketSnapshot | null { - return marketCache.get(code)?.data ?? null; + return marketCache.get(`${code}:${range}`)?.data ?? null; }