feat(market): add selectable chart ranges (24h/7d/30d/1y/all) with range-aware API caching and downsampling

This commit is contained in:
2026-03-10 19:25:23 +01:00
parent 54fe876a8c
commit 6cb038688f
8 changed files with 178 additions and 47 deletions

View File

@@ -161,9 +161,10 @@ If empty, the app uses the local default (`/api/rates`).
- `GET /api/convert?from=USD&to=BTC&amount=100` - `GET /api/convert?from=USD&to=BTC&amount=100`
- Converts between any supported fiat/crypto pair using current normalized rates. - Converts between any supported fiat/crypto pair using current normalized rates.
- Example response fields: `amount`, `convertedAmount`, `rate`, `inverseRate`, `updatedAt`, `sources`. - 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. - 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 ## Architecture Notes

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { MARKET_CHART_RANGES } from "@/lib/market";
import { getRatesWithCache } from "@/lib/server/rates-cache"; import { getRatesWithCache } from "@/lib/server/rates-cache";
import { import {
getCryptoMarketWithCache, getCryptoMarketWithCache,
@@ -10,6 +11,7 @@ import {
const querySchema = z.object({ const querySchema = z.object({
code: z.string().trim().toUpperCase().min(1), code: z.string().trim().toUpperCase().min(1),
range: z.enum(MARKET_CHART_RANGES).default("24h"),
}); });
export const revalidate = 60; export const revalidate = 60;
@@ -18,6 +20,7 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const queryResult = querySchema.safeParse({ const queryResult = querySchema.safeParse({
code: searchParams.get("code") ?? "", code: searchParams.get("code") ?? "",
range: searchParams.get("range") ?? undefined,
}); });
if (!queryResult.success) { if (!queryResult.success) {
@@ -32,7 +35,7 @@ export async function GET(request: NextRequest) {
); );
} }
const { code } = queryResult.data; const { code, range } = queryResult.data;
try { try {
const rates = await getRatesWithCache(); const rates = await getRatesWithCache();
@@ -56,6 +59,7 @@ export async function GET(request: NextRequest) {
code: asset.code, code: asset.code,
name: asset.name, name: asset.name,
providerId: asset.providerId, providerId: asset.providerId,
range,
}); });
return NextResponse.json(market, { return NextResponse.json(market, {
@@ -65,7 +69,7 @@ export async function GET(request: NextRequest) {
}, },
}); });
} catch (error) { } catch (error) {
const fallback = getLastCachedCryptoMarket(code); const fallback = getLastCachedCryptoMarket(code, range);
if (fallback) { if (fallback) {
return NextResponse.json(fallback, { return NextResponse.json(fallback, {

View File

@@ -29,6 +29,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useCryptoMarket } from "@/hooks/use-crypto-market"; import { useCryptoMarket } from "@/hooks/use-crypto-market";
import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { getDisplaySymbol } from "@/lib/currency-display"; import { getDisplaySymbol } from "@/lib/currency-display";
import {
MARKET_CHART_RANGES,
type MarketChartRange,
} from "@/lib/market";
import { useMarketRates } from "@/hooks/use-market-rates"; import { useMarketRates } from "@/hooks/use-market-rates";
import { import {
formatAmount, formatAmount,
@@ -48,6 +52,13 @@ const DEFAULT_TO = "EUR";
const QUICK_AMOUNTS = [10, 50, 100, 500, 1000] as const; const QUICK_AMOUNTS = [10, 50, 100, 500, 1000] as const;
const DEFAULT_MULTI_CONVERSION_CODES = ["USD", "EUR", "BTC", "ETH", "SOL"] as const; const DEFAULT_MULTI_CONVERSION_CODES = ["USD", "EUR", "BTC", "ETH", "SOL"] as const;
const MAX_MULTI_CONVERSIONS = 4; const MAX_MULTI_CONVERSIONS = 4;
const MARKET_RANGE_LABELS: Record<MarketChartRange, string> = {
"24h": "24h",
"7d": "7d",
"30d": "30d",
"1y": "1y",
all: "all",
};
interface ConverterCardProps { interface ConverterCardProps {
forcedFromCode?: string; forcedFromCode?: string;
@@ -135,6 +146,7 @@ export function ConverterCard({
const [fromCode, setFromCode] = useState(DEFAULT_FROM); const [fromCode, setFromCode] = useState(DEFAULT_FROM);
const [toCode, setToCode] = useState(DEFAULT_TO); const [toCode, setToCode] = useState(DEFAULT_TO);
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const [marketRange, setMarketRange] = useState<MarketChartRange>("24h");
const debouncedAmount = useDebouncedValue(amountInput, 120); const debouncedAmount = useDebouncedValue(amountInput, 120);
@@ -259,7 +271,7 @@ export function ConverterCard({
error: marketError, error: marketError,
isLoading: isMarketLoading, isLoading: isMarketLoading,
refresh: refreshMarket, refresh: refreshMarket,
} = useCryptoMarket(marketAsset?.code ?? null); } = useCryptoMarket(marketAsset?.code ?? null, marketRange);
const handleCopyConvertedValue = async () => { const handleCopyConvertedValue = async () => {
if (convertedValue === null || !toAsset) { if (convertedValue === null || !toAsset) {
@@ -295,6 +307,35 @@ export function ConverterCard({
return Number.isFinite(latest) ? new Date(latest).toISOString() : data.updatedAt; return Number.isFinite(latest) ? new Date(latest).toISOString() : data.updatedAt;
}, [data, marketData?.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 amountPrefix = getDisplaySymbol(fromAsset);
const amountInputPaddingLeft = useMemo(() => { const amountInputPaddingLeft = useMemo(() => {
if (!amountPrefix) { if (!amountPrefix) {
@@ -627,6 +668,30 @@ export function ConverterCard({
</span> </span>
</div> </div>
<div className="mt-3 flex flex-wrap gap-1.5">
{MARKET_CHART_RANGES.map((range) => {
const isActive = marketRange === range;
return (
<Button
key={range}
type="button"
variant="outline"
size="sm"
onClick={() => setMarketRange(range)}
className={cn(
"h-7 rounded-full border-border/70 px-3 text-xs text-muted-foreground hover:text-foreground",
isActive ? "border-cyan-300/50 bg-cyan-500/15 text-cyan-100" : "",
)}
aria-pressed={isActive}
aria-label={`Show ${MARKET_RANGE_LABELS[range]} chart`}
>
{MARKET_RANGE_LABELS[range]}
</Button>
);
})}
</div>
{marketError && !marketData ? ( {marketError && !marketData ? (
<p className="mt-3 text-xs text-red-300/90"> <p className="mt-3 text-xs text-red-300/90">
Unable to load market data right now. Unable to load market data right now.
@@ -634,7 +699,8 @@ export function ConverterCard({
) : null} ) : null}
<PriceSparkline <PriceSparkline
points={marketData?.priceHistory24h ?? []} points={marketData?.priceHistory ?? []}
rangeLabel={MARKET_RANGE_LABELS[marketRange]}
isLoading={isMarketLoading && !marketData} isLoading={isMarketLoading && !marketData}
className="mt-3" className="mt-3"
/> />
@@ -650,23 +716,21 @@ export function ConverterCard({
</div> </div>
<div className="rounded-lg border border-border/60 bg-background/60 p-3"> <div className="rounded-lg border border-border/60 bg-background/60 p-3">
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
24h {MARKET_RANGE_LABELS[marketRange]}
</p> </p>
<p <p
className={cn( className={cn(
"mt-1 text-base font-medium text-foreground", "mt-1 text-base font-medium text-foreground",
marketData && marketData.change24hPct !== null marketRangeChangePct !== null
? marketData.change24hPct > 0 ? marketRangeChangePct > 0
? "text-emerald-300" ? "text-emerald-300"
: marketData.change24hPct < 0 : marketRangeChangePct < 0
? "text-red-300" ? "text-red-300"
: "text-foreground" : "text-foreground"
: "text-foreground", : "text-foreground",
)} )}
> >
{marketData {marketData ? formatSignedPercent(marketRangeChangePct) : "-"}
? formatSignedPercent(marketData.change24hPct)
: "-"}
</p> </p>
</div> </div>
<div className="rounded-lg border border-border/60 bg-background/60 p-3"> <div className="rounded-lg border border-border/60 bg-background/60 p-3">

View File

@@ -11,12 +11,14 @@ interface PriceSparklinePoint {
interface PriceSparklineProps { interface PriceSparklineProps {
points: PriceSparklinePoint[]; points: PriceSparklinePoint[];
rangeLabel: string;
isLoading?: boolean; isLoading?: boolean;
className?: string; className?: string;
} }
export function PriceSparkline({ export function PriceSparkline({
points, points,
rangeLabel,
isLoading = false, isLoading = false,
className, className,
}: PriceSparklineProps) { }: PriceSparklineProps) {
@@ -82,7 +84,7 @@ export function PriceSparkline({
)} )}
> >
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
Price (24h) Price ({rangeLabel})
</p> </p>
{chart ? ( {chart ? (
@@ -91,7 +93,7 @@ export function PriceSparkline({
viewBox={`0 0 ${chart.width} ${chart.height}`} viewBox={`0 0 ${chart.width} ${chart.height}`}
className="h-20 w-full" className="h-20 w-full"
role="img" role="img"
aria-label="Price trend over the last 24 hours" aria-label={`Price trend over the last ${rangeLabel}`}
preserveAspectRatio="none" preserveAspectRatio="none"
shapeRendering="geometricPrecision" shapeRendering="geometricPrecision"
> >
@@ -130,7 +132,9 @@ export function PriceSparkline({
</div> </div>
) : ( ) : (
<div className="mt-2 flex h-20 items-center justify-center rounded-md border border-dashed border-border/60 text-xs text-muted-foreground"> <div className="mt-2 flex h-20 items-center justify-center rounded-md border border-dashed border-border/60 text-xs text-muted-foreground">
{isLoading ? "Loading 24h price history..." : "24h price history unavailable"} {isLoading
? `Loading ${rangeLabel} price history...`
: `${rangeLabel} price history unavailable`}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,13 +4,17 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { buildApiUrl } from "@/lib/api/url"; import { buildApiUrl } from "@/lib/api/url";
import { import {
type MarketChartRange,
parseCryptoMarketResponse, parseCryptoMarketResponse,
type CryptoMarketResponse, type CryptoMarketResponse,
} from "@/lib/market"; } from "@/lib/market";
const REFRESH_INTERVAL_MS = 60_000; 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<CryptoMarketResponse | null>(null); const [data, setData] = useState<CryptoMarketResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -18,7 +22,7 @@ export function useCryptoMarket(assetCode: string | null) {
useEffect(() => { useEffect(() => {
setData(null); setData(null);
setError(null); setError(null);
}, [assetCode]); }, [assetCode, range]);
const fetchMarket = useCallback(async () => { const fetchMarket = useCallback(async () => {
if (!assetCode) { if (!assetCode) {
@@ -32,7 +36,9 @@ export function useCryptoMarket(assetCode: string | null) {
try { try {
const response = await fetch( const response = await fetch(
buildApiUrl(`/api/market?code=${encodeURIComponent(assetCode)}`), buildApiUrl(
`/api/market?code=${encodeURIComponent(assetCode)}&range=${encodeURIComponent(range)}`,
),
); );
if (!response.ok) { if (!response.ok) {
@@ -53,7 +59,7 @@ export function useCryptoMarket(assetCode: string | null) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [assetCode]); }, [assetCode, range]);
useEffect(() => { useEffect(() => {
void fetchMarket(); void fetchMarket();
@@ -80,7 +86,7 @@ export function useCryptoMarket(assetCode: string | null) {
window.clearInterval(id); window.clearInterval(id);
document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("visibilitychange", handleVisibilityChange);
}; };
}, [assetCode, fetchMarket]); }, [assetCode, range, fetchMarket]);
return useMemo( return useMemo(
() => ({ () => ({

View File

@@ -1,4 +1,5 @@
import { CRYPTO_ASSETS } from "@/lib/assets"; import { CRYPTO_ASSETS } from "@/lib/assets";
import type { MarketChartRange } from "@/lib/market";
export interface CryptoRateResult { export interface CryptoRateResult {
usdPrices: Record<string, number>; usdPrices: Record<string, number>;
@@ -19,6 +20,13 @@ export interface CryptoPricePoint {
} }
const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"; const COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3";
const COINGECKO_RANGE_TO_DAYS: Record<MarketChartRange, string> = {
"24h": "1",
"7d": "7",
"30d": "30",
"1y": "365",
all: "max",
};
function buildCoinGeckoHeaders(): HeadersInit { function buildCoinGeckoHeaders(): HeadersInit {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -96,12 +104,14 @@ export async function fetchCryptoMarketSnapshot(
}; };
} }
export async function fetchCryptoPriceHistory24h( export async function fetchCryptoPriceHistory(
providerId: string, providerId: string,
range: MarketChartRange,
): Promise<CryptoPricePoint[]> { ): Promise<CryptoPricePoint[]> {
const days = COINGECKO_RANGE_TO_DAYS[range];
const url = `${COINGECKO_BASE_URL}/coins/${encodeURIComponent( const url = `${COINGECKO_BASE_URL}/coins/${encodeURIComponent(
providerId, providerId,
)}/market_chart?vs_currency=usd&days=1`; )}/market_chart?vs_currency=usd&days=${encodeURIComponent(days)}`;
const response = await fetch(url, { const response = await fetch(url, {
next: { revalidate: 60 }, next: { revalidate: 60 },

View File

@@ -1,5 +1,8 @@
import { z } from "zod"; 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 { export interface CryptoMarketResponse {
code: string; code: string;
name: string; name: string;
@@ -7,7 +10,8 @@ export interface CryptoMarketResponse {
change24hPct: number | null; change24hPct: number | null;
marketCapUsd: number | null; marketCapUsd: number | null;
volume24hUsd: number | null; volume24hUsd: number | null;
priceHistory24h: Array<{ priceHistoryRange: MarketChartRange;
priceHistory: Array<{
timestamp: string; timestamp: string;
priceUsd: number; priceUsd: number;
}>; }>;
@@ -15,25 +19,37 @@ export interface CryptoMarketResponse {
source: string; source: string;
} }
const marketResponseSchema = z.object({ const pointSchema = z.object({
timestamp: z.string(),
priceUsd: z.number().positive(),
});
const marketResponseSchema = z
.object({
code: z.string(), code: z.string(),
name: z.string(), name: z.string(),
priceUsd: z.number().positive(), priceUsd: z.number().positive(),
change24hPct: z.number().nullable(), change24hPct: z.number().nullable(),
marketCapUsd: z.number().nullable(), marketCapUsd: z.number().nullable(),
volume24hUsd: z.number().nullable(), volume24hUsd: z.number().nullable(),
priceHistory24h: z priceHistoryRange: z.enum(MARKET_CHART_RANGES),
.array( priceHistory: z.array(pointSchema).optional(),
z.object({ priceHistory24h: z.array(pointSchema).optional(),
timestamp: z.string(),
priceUsd: z.number().positive(),
}),
)
.optional()
.default([]),
updatedAt: z.string(), updatedAt: z.string(),
source: 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( export function parseCryptoMarketResponse(
payload: unknown, payload: unknown,

View File

@@ -1,7 +1,8 @@
import { import {
fetchCryptoMarketSnapshot, fetchCryptoMarketSnapshot,
fetchCryptoPriceHistory24h, fetchCryptoPriceHistory,
} from "@/lib/api/crypto"; } from "@/lib/api/crypto";
import type { MarketChartRange } from "@/lib/market";
export interface CachedCryptoMarketSnapshot { export interface CachedCryptoMarketSnapshot {
code: string; code: string;
@@ -10,7 +11,8 @@ export interface CachedCryptoMarketSnapshot {
change24hPct: number | null; change24hPct: number | null;
marketCapUsd: number | null; marketCapUsd: number | null;
volume24hUsd: number | null; volume24hUsd: number | null;
priceHistory24h: Array<{ priceHistoryRange: MarketChartRange;
priceHistory: Array<{
timestamp: string; timestamp: string;
priceUsd: number; priceUsd: number;
}>; }>;
@@ -30,12 +32,33 @@ export const MARKET_CACHE_CONTROL_VALUE =
const marketCache = new Map<string, CacheEntry>(); const marketCache = new Map<string, CacheEntry>();
const inFlightRequests = new Map<string, Promise<CachedCryptoMarketSnapshot>>(); const inFlightRequests = new Map<string, Promise<CachedCryptoMarketSnapshot>>();
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: { export async function getCryptoMarketWithCache(params: {
code: string; code: string;
name: string; name: string;
providerId: string; providerId: string;
range: MarketChartRange;
}): Promise<CachedCryptoMarketSnapshot> { }): Promise<CachedCryptoMarketSnapshot> {
const cacheKey = params.code; const cacheKey = `${params.code}:${params.range}`;
const cached = marketCache.get(cacheKey); const cached = marketCache.get(cacheKey);
const now = Date.now(); const now = Date.now();
@@ -54,7 +77,8 @@ export async function getCryptoMarketWithCache(params: {
let history: Array<{ timestamp: string; priceUsd: number }> = []; let history: Array<{ timestamp: string; priceUsd: number }> = [];
try { try {
history = await fetchCryptoPriceHistory24h(params.providerId); history = await fetchCryptoPriceHistory(params.providerId, params.range);
history = downsamplePriceHistory(history, 180);
} catch { } catch {
history = []; history = [];
} }
@@ -66,7 +90,8 @@ export async function getCryptoMarketWithCache(params: {
change24hPct: snapshot.change24hPct, change24hPct: snapshot.change24hPct,
marketCapUsd: snapshot.marketCapUsd, marketCapUsd: snapshot.marketCapUsd,
volume24hUsd: snapshot.volume24hUsd, volume24hUsd: snapshot.volume24hUsd,
priceHistory24h: history, priceHistoryRange: params.range,
priceHistory: history,
updatedAt: snapshot.updatedAt, updatedAt: snapshot.updatedAt,
source: "CoinGecko", source: "CoinGecko",
}; };
@@ -90,6 +115,7 @@ export async function getCryptoMarketWithCache(params: {
export function getLastCachedCryptoMarket( export function getLastCachedCryptoMarket(
code: string, code: string,
range: MarketChartRange,
): CachedCryptoMarketSnapshot | null { ): CachedCryptoMarketSnapshot | null {
return marketCache.get(code)?.data ?? null; return marketCache.get(`${code}:${range}`)?.data ?? null;
} }