feat(market): add selectable chart ranges (24h/7d/30d/1y/all) with range-aware API caching and downsampling
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { CRYPTO_ASSETS } from "@/lib/assets";
|
||||
import type { MarketChartRange } from "@/lib/market";
|
||||
|
||||
export interface CryptoRateResult {
|
||||
usdPrices: Record<string, number>;
|
||||
@@ -19,6 +20,13 @@ export interface CryptoPricePoint {
|
||||
}
|
||||
|
||||
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 {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -96,12 +104,14 @@ export async function fetchCryptoMarketSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCryptoPriceHistory24h(
|
||||
export async function fetchCryptoPriceHistory(
|
||||
providerId: string,
|
||||
range: MarketChartRange,
|
||||
): Promise<CryptoPricePoint[]> {
|
||||
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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, CacheEntry>();
|
||||
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: {
|
||||
code: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
range: MarketChartRange;
|
||||
}): Promise<CachedCryptoMarketSnapshot> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user