feat(market): add 24h crypto sparkline chart with CoinGecko history and resilient fallback

This commit is contained in:
2026-03-10 15:12:48 +01:00
parent afb000f16b
commit 4537ce8b9c
6 changed files with 217 additions and 2 deletions

View File

@@ -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

View File

@@ -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({
</p>
) : null}
<PriceSparkline
points={marketData?.priceHistory24h ?? []}
isLoading={isMarketLoading && !marketData}
className="mt-3"
/>
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">

View File

@@ -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 (
<div
className={cn(
"rounded-lg border border-border/60 bg-background/60 p-3",
className,
)}
>
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
Price (24h)
</p>
{chart ? (
<div className="mt-2">
<svg
viewBox={`0 0 ${chart.width} ${chart.height}`}
className="h-20 w-full"
role="img"
aria-label="Price trend over the last 24 hours"
preserveAspectRatio="none"
shapeRendering="geometricPrecision"
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={chart.isUptrend ? "#34d399" : "#fb7185"}
stopOpacity="0.26"
/>
<stop
offset="100%"
stopColor={chart.isUptrend ? "#34d399" : "#fb7185"}
stopOpacity="0"
/>
</linearGradient>
</defs>
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
<path
d={chart.linePath}
fill="none"
stroke={chart.isUptrend ? "#34d399" : "#fb7185"}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
strokeLinecap="round"
strokeLinejoin="bevel"
/>
</svg>
</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">
{isLoading ? "Loading 24h price history..." : "24h price history unavailable"}
</div>
)}
</div>
);
}

View File

@@ -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<CryptoPricePoint[]> {
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<CryptoRateResult> {
const ids = CRYPTO_ASSETS.map((asset) => asset.providerId).filter(
(id): id is string => Boolean(id)

View File

@@ -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(),
});

View File

@@ -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",
};