597 lines
20 KiB
TypeScript
597 lines
20 KiB
TypeScript
import { Link, useNavigate } from "@tanstack/react-router";
|
|
import { subDays, subMonths } 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,
|
|
useMonthlyLast,
|
|
useQuotes,
|
|
} from "@/lib/queries";
|
|
import { MonthlyVariationTable } from "./components/MonthlyVariationTable";
|
|
|
|
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 { data: beloDaily, isLoading: beloLoading } = useDailyQuotes(
|
|
"BELO",
|
|
startDateStr,
|
|
endDateStr,
|
|
);
|
|
const { data: blueDaily, isLoading: blueLoading } = useDailyQuotes(
|
|
"BLUE",
|
|
startDateStr,
|
|
endDateStr,
|
|
);
|
|
const { data: bnaDaily, isLoading: bnaLoading } = useDailyQuotes(
|
|
"BNA",
|
|
startDateStr,
|
|
endDateStr,
|
|
);
|
|
|
|
const yesterday = subDays(today, 1);
|
|
const yesterdayStr = formatDate(yesterday);
|
|
|
|
const { data: yesterdayQuote } = useDailyQuotes(
|
|
currency,
|
|
yesterdayStr,
|
|
yesterdayStr,
|
|
);
|
|
const { data: monthlyLastData } = useMonthlyLast();
|
|
|
|
const chartData = useMemo(() => {
|
|
if (!daily) return [];
|
|
return daily.map((d) => ({
|
|
date: formatShortDate(d.date),
|
|
buy: d.buy,
|
|
}));
|
|
}, [daily]);
|
|
|
|
const comparisonData = useMemo(() => {
|
|
const map = new Map<
|
|
string,
|
|
{ date: string; BELO?: number; BLUE?: number; BNA?: number }
|
|
>();
|
|
const add = (
|
|
rows: { date: string; buy: number }[] | undefined,
|
|
key: "BELO" | "BLUE" | "BNA",
|
|
) => {
|
|
rows?.forEach((d) => {
|
|
const entry = map.get(d.date) ?? { date: d.date };
|
|
entry[key] = d.buy;
|
|
map.set(d.date, entry);
|
|
});
|
|
};
|
|
add(beloDaily, "BELO");
|
|
add(blueDaily, "BLUE");
|
|
add(bnaDaily, "BNA");
|
|
return [...map.values()]
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.map(({ date, ...rest }) => ({ date: formatShortDate(date), ...rest }));
|
|
}, [beloDaily, blueDaily, bnaDaily]);
|
|
|
|
const comparisonDomain = useMemo(() => {
|
|
if (comparisonData.length === 0) return [0, 0] as [number, number];
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
for (const d of comparisonData) {
|
|
for (const key of ["BELO", "BLUE", "BNA"] as const) {
|
|
const value = d[key];
|
|
if (value === undefined) continue;
|
|
if (value < min) min = value;
|
|
if (value > max) max = value;
|
|
}
|
|
}
|
|
if (!Number.isFinite(min) || !Number.isFinite(max))
|
|
return [0, 0] as [number, number];
|
|
const range = max - min;
|
|
return [min - range * 0.2, max + range * 0.2] as [number, number];
|
|
}, [comparisonData]);
|
|
|
|
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 previousMonthBuy = useMemo(() => {
|
|
if (!monthlyLastData || monthlyLastData.length === 0) return null;
|
|
const prevMonth = subMonths(today, 1);
|
|
const prevMonthKey = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, "0")}`;
|
|
const entry = monthlyLastData.find(
|
|
(e) => e.month === prevMonthKey && e.type === currency,
|
|
);
|
|
return entry?.buy ?? null;
|
|
}, [monthlyLastData, currency, today]);
|
|
|
|
const monthDiff =
|
|
previousMonthBuy !== null ? currentBuy - previousMonthBuy : null;
|
|
const monthPct =
|
|
monthDiff !== null && previousMonthBuy !== null && previousMonthBuy !== 0
|
|
? (monthDiff / previousMonthBuy) * 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. mes anterior</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 className="rounded-lg border p-4">
|
|
<h2 className="text-sm font-medium mb-4">
|
|
Precio de compra por cotización
|
|
</h2>
|
|
|
|
{beloLoading || blueLoading || bnaLoading ? (
|
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
|
Cargando...
|
|
</div>
|
|
) : comparisonData.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={comparisonData}>
|
|
<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={comparisonDomain}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value: unknown, name: unknown) => [
|
|
`$${priceFormatter.format(Number(value))}`,
|
|
CURRENCY_LABELS[String(name)] ?? String(name),
|
|
]}
|
|
labelFormatter={(label: unknown) => `Fecha: ${label}`}
|
|
contentStyle={{
|
|
backgroundColor: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius)",
|
|
fontSize: 13,
|
|
}}
|
|
/>
|
|
<Line
|
|
type="linear"
|
|
dataKey="BELO"
|
|
name="BELO"
|
|
stroke="var(--chart-1)"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 4 }}
|
|
isAnimationActive={false}
|
|
/>
|
|
<Line
|
|
type="linear"
|
|
dataKey="BLUE"
|
|
name="BLUE"
|
|
stroke="var(--chart-2)"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 4 }}
|
|
isAnimationActive={false}
|
|
/>
|
|
<Line
|
|
type="linear"
|
|
dataKey="BNA"
|
|
name="BNA"
|
|
stroke="var(--chart-4)"
|
|
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">BELO</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="size-2.5 rounded-full"
|
|
style={{ backgroundColor: "var(--chart-2)" }}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
Dólar Blue
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="size-2.5 rounded-full"
|
|
style={{ backgroundColor: "var(--chart-4)" }}
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
Dólar Oficial
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-lg border p-4">
|
|
<h2 className="text-sm font-medium mb-4">
|
|
Variación mensual vs. inflación
|
|
</h2>
|
|
<MonthlyVariationTable />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|