feat(analysis): make analysis page work for all currencies (BELO, BLUE, BNA)

- Replace hardcoded BELO analysis with generic AnalysisPage
- Add /quotes/analysis/ route with currency path param
- Add currency selector tabs on analysis page
- Dashboard QuoteCards now navigate to analysis for all currencies
- Old /quotes/belo/analysis redirects to /quotes/analysis/BELO
This commit is contained in:
Jose Selesan
2026-06-12 16:44:20 -03:00
parent 142fd4e33f
commit c0c8bf0945
5 changed files with 79 additions and 17 deletions

View File

@@ -0,0 +1,407 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { subDays } from "date-fns";
import { ArrowLeft } from "lucide-react";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { DatePicker } from "@/components/ui/date-picker";
import { useDailyQuotes, useHistoricalMinMax, useQuotes } from "@/lib/queries";
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
const CURRENCY_LABELS: Record<string, string> = {
BELO: "BELO",
BLUE: "Dólar Blue",
BNA: "Dólar Oficial",
};
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const dateFormatter = new Intl.DateTimeFormat("es-AR", {
day: "numeric",
month: "long",
year: "numeric",
});
function formatShortDate(iso: string): string {
const datePart = iso.split(" ")[0];
const [y, m, d] = datePart.split("-").map(Number);
return dateFormatter.format(new Date(y, m - 1, d));
}
export function AnalysisPage({ currency }: { currency: string }) {
const navigate = useNavigate();
const today = new Date();
const defaultStart = subDays(today, 30);
const [startDate, setStartDate] = useState<Date>(defaultStart);
const [endDate, setEndDate] = useState<Date>(today);
const startDateStr = formatDate(startDate);
const endDateStr = formatDate(endDate);
const { data: historical, isLoading: historicalLoading } =
useHistoricalMinMax(currency);
const { data: quotes } = useQuotes();
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
currency,
startDateStr,
endDateStr,
);
const yesterday = subDays(today, 1);
const yesterdayStr = formatDate(yesterday);
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const firstOfMonthStr = formatDate(firstOfMonth);
const { data: yesterdayQuote } = useDailyQuotes(
currency,
yesterdayStr,
yesterdayStr,
);
const { data: firstDayQuote } = useDailyQuotes(
currency,
firstOfMonthStr,
firstOfMonthStr,
);
const chartData = useMemo(() => {
if (!daily) return [];
return daily.map((d) => ({
date: formatShortDate(d.date),
buy: d.buy,
}));
}, [daily]);
const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => {
if (chartData.length === 0)
return {
yDomain: [0, 0] as [number, number],
minBuyValue: 0,
maxBuyValue: 0,
avgBuyValue: 0,
};
let min = Infinity;
let max = -Infinity;
let sum = 0;
for (const d of chartData) {
if (d.buy < min) min = d.buy;
if (d.buy > max) max = d.buy;
sum += d.buy;
}
const range = max - min || 1;
return {
yDomain: [min - range * 0.1, max + range * 0.1] as [number, number],
minBuyValue: min,
maxBuyValue: max,
avgBuyValue: sum / chartData.length,
};
}, [chartData]);
const currentQuote = quotes?.find((q) => q.type === currency);
const currentBuy = currentQuote ? Number(currentQuote.buy) : 0;
const yesterdayBuy = yesterdayQuote?.[0]?.buy ?? null;
const prevDayDiff = yesterdayBuy !== null ? currentBuy - yesterdayBuy : null;
const prevDayPct =
prevDayDiff !== null && yesterdayBuy !== null && yesterdayBuy !== 0
? (prevDayDiff / yesterdayBuy) * 100
: null;
const firstMonthBuy = firstDayQuote?.[0]?.buy ?? null;
const monthDiff = firstMonthBuy !== null ? currentBuy - firstMonthBuy : null;
const monthPct =
monthDiff !== null && firstMonthBuy !== null && firstMonthBuy !== 0
? (monthDiff / firstMonthBuy) * 100
: null;
const backTo = currency === "BELO" ? "/quotes/belo" : "/quotes";
const backLabel = currency === "BELO" ? "BELO" : "Cotizaciones";
function VariationValue({
value,
pct,
}: {
value: number | null;
pct: number | null;
}) {
if (value === null || pct === null)
return <p className="text-sm text-muted-foreground">Sin datos</p>;
const isPositive = value >= 0;
const color = isPositive ? "text-emerald-500" : "text-red-500";
const sign = isPositive ? "+" : "";
return (
<p className={`text-lg font-bold tabular-nums ${color}`}>
{sign}${priceFormatter.format(Math.abs(value))}{" "}
<span className="text-sm font-normal">
({sign}
{pct.toFixed(2)}%)
</span>
</p>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to={backTo}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
{backLabel}
</Link>
<h1 className="text-2xl font-semibold">Análisis</h1>
</div>
<div className="flex items-center gap-1 rounded-lg border p-0.5">
{CURRENCIES.map((c) => (
<button
key={c}
type="button"
onClick={() =>
navigate({ to: "/quotes/analysis/$currency", params: { currency: c } })
}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
currency === c
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{CURRENCY_LABELS[c]}
</button>
))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Cotización actual (compra)
</p>
<p className="text-2xl font-bold">
${priceFormatter.format(currentBuy)}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Mínimo histórico (compra)
</p>
{historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
<p className="text-2xl font-bold">
${priceFormatter.format(historical?.minBuy ?? 0)}
</p>
{historical?.minBuyDate && (
<p className="text-xs text-muted-foreground mt-0.5">
el {formatShortDate(historical.minBuyDate)}
</p>
)}
</>
)}
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Máximo histórico (compra)
</p>
{historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
<p className="text-2xl font-bold">
${priceFormatter.format(historical?.maxBuy ?? 0)}
</p>
{historical?.maxBuyDate && (
<p className="text-xs text-muted-foreground mt-0.5">
el {formatShortDate(historical.maxBuyDate)}
</p>
)}
</>
)}
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">Vs. día anterior</p>
<VariationValue value={prevDayDiff} pct={prevDayPct} />
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Vs. primer día del mes
</p>
<VariationValue value={monthDiff} pct={monthPct} />
</div>
</div>
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Evolución del precio de compra
</h2>
<div className="flex items-center gap-4 mb-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Desde</span>
<DatePicker
value={startDate}
onChange={(d) => d && setStartDate(d)}
placeholder="Fecha inicio"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Hasta</span>
<DatePicker
value={endDate}
onChange={(d) => d && setEndDate(d)}
placeholder="Fecha fin"
/>
</div>
</div>
{dailyLoading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Cargando...
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Sin datos para este período
</div>
) : (
<>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-muted-foreground"
tickFormatter={(v: number) =>
`$${priceFormatter.format(v)}`
}
width={80}
domain={yDomain}
/>
<Tooltip
formatter={(value: unknown) => [
`$${priceFormatter.format(Number(value))}`,
"Compra",
]}
labelFormatter={(label: unknown) => `Fecha: ${label}`}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
fontSize: 13,
}}
/>
<ReferenceLine
y={maxBuyValue}
stroke="var(--chart-1)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Máx: $${priceFormatter.format(maxBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-1)",
}}
/>
<ReferenceLine
y={minBuyValue}
stroke="var(--chart-2)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Mín: $${priceFormatter.format(minBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-2)",
}}
/>
<ReferenceLine
y={avgBuyValue}
stroke="var(--chart-3)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Prom: $${priceFormatter.format(avgBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-3)",
}}
/>
<Line
type="linear"
dataKey="buy"
name="Compra"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Compra</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Máx</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">Mín</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-3)" }}
/>
<span className="text-xs text-muted-foreground">Prom</span>
</div>
</div>
</>
)}
</div>
</div>
);
}