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