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

@@ -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<MarketChartRange, string> = {
"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<MarketChartRange>("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({
</span>
</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 ? (
<p className="mt-3 text-xs text-red-300/90">
Unable to load market data right now.
@@ -634,7 +699,8 @@ export function ConverterCard({
) : null}
<PriceSparkline
points={marketData?.priceHistory24h ?? []}
points={marketData?.priceHistory ?? []}
rangeLabel={MARKET_RANGE_LABELS[marketRange]}
isLoading={isMarketLoading && !marketData}
className="mt-3"
/>
@@ -650,23 +716,21 @@ export function ConverterCard({
</div>
<div className="rounded-lg border border-border/60 bg-background/60 p-3">
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
24h
{MARKET_RANGE_LABELS[marketRange]}
</p>
<p
className={cn(
"mt-1 text-base font-medium text-foreground",
marketData && marketData.change24hPct !== null
? marketData.change24hPct > 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) : "-"}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-background/60 p-3">

View File

@@ -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({
)}
>
<p className="text-xs uppercase tracking-[0.1em] text-muted-foreground">
Price (24h)
Price ({rangeLabel})
</p>
{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({
</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"}
{isLoading
? `Loading ${rangeLabel} price history...`
: `${rangeLabel} price history unavailable`}
</div>
)}
</div>