initial commit

This commit is contained in:
Jose Selesan
2026-05-28 14:33:16 -03:00
commit 7bc3d9f898
211 changed files with 161253 additions and 0 deletions

View File

@@ -0,0 +1,423 @@
import { useMemo, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
Gauge,
GaugeIndicator,
GaugeTrack,
GaugeRange,
GaugeValueText,
} from "@/components/ui/gauge";
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
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}`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
function formatShortDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("es-AR", {
day: "2-digit",
month: "2-digit",
});
}
function getGaugeColor(percentage: number | null): string {
if (percentage === null) return "text-muted-foreground";
if (percentage >= 0.8) return "text-emerald-500";
if (percentage >= 0.5) return "text-yellow-500";
return "text-red-500";
}
function StatCard({
label,
buy,
sell,
buyDate,
sellDate,
}: {
label: string;
buy: string | number;
sell: string | number;
buyDate?: string;
sellDate?: string;
}) {
return (
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">{label}</p>
<div className="space-y-1">
<p className="text-sm">
Compra:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(buy))}
</span>
{buyDate && (
<span className="text-[10px] text-muted-foreground ml-1.5">
{formatShortDate(buyDate)}
</span>
)}
</p>
<p className="text-sm">
Venta:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(sell))}
</span>
{sellDate && (
<span className="text-[10px] text-muted-foreground ml-1.5">
{formatShortDate(sellDate)}
</span>
)}
</p>
</div>
</div>
);
}
type Period = "day" | "month";
export function BeloPage() {
const [period, setPeriod] = useState<Period>("day");
const now = new Date();
const todayStr = formatDate(now);
const firstOfMonth = formatDate(new Date(now.getFullYear(), now.getMonth(), 1));
const { data: quotes, isLoading: quotesLoading } = useQuotes();
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax(
"BELO",
todayStr,
todayStr,
);
const { data: monthlyMinMax } = useDailyMinMax(
"BELO",
firstOfMonth,
todayStr,
);
const { data: history, isLoading: historyLoading } = useQuoteHistory(
"BELO",
todayStr,
todayStr,
);
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
"BELO",
firstOfMonth,
todayStr,
);
const belo = quotes?.find((q) => q.type === "BELO");
const maxData = dailyMinMax?.[0];
const currentBuy = belo ? Number(belo.buy) : 0;
const currentSell = belo ? Number(belo.sell) : 0;
const minBuy = maxData ? Number(maxData.minBuy) : 0;
const maxBuy = maxData ? Number(maxData.maxBuy) : 0;
const minSell = maxData ? Number(maxData.minSell) : 0;
const maxSell = maxData ? Number(maxData.maxSell) : 0;
const gaugeValue = currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
const gaugePercentage = useMemo(() => {
if (gaugeValue !== null && maxBuy > minBuy) {
return (gaugeValue - minBuy) / (maxBuy - minBuy);
}
return null;
}, [gaugeValue, minBuy, maxBuy]);
const chartData = useMemo(() => {
if (period === "day") {
if (!history) return [];
return history.map((h) => ({
time: formatTime(h.timeStamp),
buy: Number(h.buy),
sell: Number(h.sell),
}));
}
if (!daily) return [];
return daily.map((d) => ({
time: formatShortDate(d.date),
buy: d.buy,
sell: d.sell,
}));
}, [history, daily, period]);
const yDomain = useMemo(() => {
if (chartData.length === 0) return [0, 0];
let min = Infinity;
let max = -Infinity;
for (const d of chartData) {
if (d.buy < min) min = d.buy;
if (d.sell < min) min = d.sell;
if (d.buy > max) max = d.buy;
if (d.sell > max) max = d.sell;
}
const range = max - min;
return [min - range * 0.1, max + range * 0.1];
}, [chartData]);
const maxBuyValue = useMemo(() => {
if (chartData.length === 0) return 0;
let max = -Infinity;
for (const d of chartData) {
if (d.buy > max) max = d.buy;
}
return max;
}, [chartData]);
const maxSellValue = useMemo(() => {
if (chartData.length === 0) return 0;
let max = -Infinity;
for (const d of chartData) {
if (d.sell > max) max = d.sell;
}
return max;
}, [chartData]);
const monthlyMax = useMemo(() => {
if (!monthlyMinMax || monthlyMinMax.length === 0) return null;
let maxBuyEntry = monthlyMinMax[0];
let maxSellEntry = monthlyMinMax[0];
for (const d of monthlyMinMax) {
if (d.maxBuy > maxBuyEntry.maxBuy) maxBuyEntry = d;
if (d.maxSell > maxSellEntry.maxSell) maxSellEntry = d;
}
return {
maxBuy: maxBuyEntry.maxBuy,
maxBuyDate: maxBuyEntry.date,
maxSell: maxSellEntry.maxSell,
maxSellDate: maxSellEntry.date,
};
}, [monthlyMinMax]);
const isFetching = quotesLoading || fetchPending;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">BELO</h1>
<div className="flex items-center gap-3">
{belo && (
<span className="text-xs text-muted-foreground">
Últ. actualización: <RelativeTime date={belo.timeStamp} />
</span>
)}
<button
type="button"
disabled={isFetching}
onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
Actualizar
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<StatCard
label="Precio actual"
buy={currentBuy}
sell={currentSell}
/>
<StatCard
label="Mínimo del día"
buy={minBuy}
sell={minSell}
/>
<StatCard
label="Máximo del día"
buy={maxBuy}
sell={maxSell}
/>
<StatCard
label="Máximo del mes"
buy={monthlyMax?.maxBuy ?? 0}
sell={monthlyMax?.maxSell ?? 0}
buyDate={monthlyMax?.maxBuyDate}
sellDate={monthlyMax?.maxSellDate}
/>
<div className="rounded-lg border p-4 flex flex-col items-center justify-center">
<p className="text-xs text-muted-foreground mb-0.5">Avance</p>
<Gauge
value={gaugeValue}
min={minBuy}
max={maxBuy}
size={80}
thickness={6}
startAngle={0}
endAngle={360}
getValueText={(v, m, mx) => {
const pct = mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
return `${pct}%`;
}}
>
<GaugeIndicator>
<GaugeTrack />
<GaugeRange className={getGaugeColor(gaugePercentage)} />
</GaugeIndicator>
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} />
</Gauge>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium">Evolución del precio</h2>
<div className="flex items-center gap-1 rounded-lg border p-0.5">
<button
type="button"
onClick={() => setPeriod("day")}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
period === "day"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Día
</button>
<button
type="button"
onClick={() => setPeriod("month")}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
period === "month"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Mes
</button>
</div>
</div>
{(period === "day" ? historyLoading : 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="time"
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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any) => [
`$${priceFormatter.format(Number(value))}`,
]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
labelFormatter={(label: any) =>
period === "day" ? `Hora: ${label}` : `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 compra: $${priceFormatter.format(maxBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-1)",
}}
/>
<ReferenceLine
y={maxSellValue}
stroke="var(--chart-2)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Máx venta: $${priceFormatter.format(maxSellValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-2)",
}}
/>
<Line
type="monotone"
dataKey="buy"
name="Compra"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="sell"
name="Venta"
stroke="var(--chart-2)"
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">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} />
<span className="text-xs text-muted-foreground">Venta</span>
</div>
</div>
</div>
</div>
);
}