- Install @biomejs/biome as devDependency at root - Configure biome.json with 2-space indent, double quotes, Tailwind CSS support - Add lint/lint:fix/format/format:fix scripts to root and app package.json - Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button> - Fix noUnusedVariables: remove unused destructured vars - Fix useIterableCallbackReturn: arrow functions with block body - Fix noExplicitAny: recharts Tooltip formatters - Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels - Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays - Fix noArrayIndexKey: use error string as key - Fix CSS parse: enable tailwindDirectives parser - Normalize formatting across 80 files with biome check --write
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import { Link } from "@tanstack/react-router";
|
|
import { BarChart3, RefreshCw } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
CartesianGrid,
|
|
Line,
|
|
LineChart,
|
|
ReferenceLine,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from "recharts";
|
|
import {
|
|
Gauge,
|
|
GaugeIndicator,
|
|
GaugeRange,
|
|
GaugeTrack,
|
|
GaugeValueText,
|
|
} from "@/components/ui/gauge";
|
|
import {
|
|
useDailyMinMax,
|
|
useDailyQuotes,
|
|
useFetchQuotes,
|
|
useQuoteHistory,
|
|
useQuotes,
|
|
} from "@/lib/queries";
|
|
import { RelativeTime } from "@/lib/time";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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 } = 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>
|
|
)}
|
|
<Link
|
|
to="/quotes/belo/analysis"
|
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<BarChart3 className="h-4 w-4" />
|
|
Análisis
|
|
</Link>
|
|
<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
|
|
// biome-ignore lint/suspicious/noExplicitAny: recharts types
|
|
formatter={(value: any) => [
|
|
`$${priceFormatter.format(Number(value))}`,
|
|
]}
|
|
// biome-ignore lint/suspicious/noExplicitAny: recharts types
|
|
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>
|
|
);
|
|
}
|