feat: add BELO peak hour analysis chart

- New backend endpoint GET /quotes/:type/peak-hours
- Groups daily price data by hour to find peak frequency
- Bar chart showing how often each hour is the daily peak
- Only displayed for BELO currency in analysis page
This commit is contained in:
Jose Selesan
2026-08-28 12:05:43 -03:00
parent 2fe8198c41
commit 0998d64cbb
6 changed files with 339 additions and 0 deletions

View File

@@ -17,9 +17,11 @@ import {
useDailyQuotes,
useHistoricalMinMax,
useMonthlyLast,
usePeakHours,
useQuotes,
} from "@/lib/queries";
import { MonthlyVariationTable } from "./components/MonthlyVariationTable";
import { PeakHoursChart } from "./components/PeakHoursChart";
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
@@ -99,6 +101,12 @@ export function AnalysisPage({ currency }: { currency: string }) {
);
const { data: monthlyLastData } = useMonthlyLast();
const { data: peakHoursData, isLoading: peakHoursLoading } = usePeakHours(
"BELO",
startDateStr,
endDateStr,
);
const chartData = useMemo(() => {
if (!daily) return [];
return daily.map((d) => ({
@@ -591,6 +599,19 @@ export function AnalysisPage({ currency }: { currency: string }) {
</h2>
<MonthlyVariationTable />
</div>
{currency === "BELO" && (
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Análisis de pico horario
</h2>
<p className="text-xs text-muted-foreground mb-4">
Frecuencia con la que cada hora del día fue el momento de mayor
precio de compra dentro de cada día.
</p>
<PeakHoursChart data={peakHoursData} isLoading={peakHoursLoading} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { PeakHoursResponse } from "@/lib/api";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
type ChartEntry = {
hour: string;
frequency: number;
avgPeakBuy: number;
minPeakBuy: number;
maxPeakBuy: number;
isPeak: boolean;
};
export function PeakHoursChart({
data,
isLoading,
}: {
data: PeakHoursResponse | undefined;
isLoading: boolean;
}) {
const { chartData, peakHour, totalDays } = useMemo(() => {
if (!data || data.peaks.length === 0) {
return { chartData: [], peakHour: null, totalDays: 0 };
}
const maxFreq = Math.max(...data.peaks.map((p) => p.frequency));
const allHours: ChartEntry[] = Array.from({ length: 24 }, (_, i) => {
const found = data.peaks.find((p) => p.hour === i);
return {
hour: `${String(i).padStart(2, "0")}hs`,
frequency: found?.frequency ?? 0,
avgPeakBuy: found?.avgPeakBuy ?? 0,
minPeakBuy: found?.minPeakBuy ?? 0,
maxPeakBuy: found?.maxPeakBuy ?? 0,
isPeak: found?.frequency === maxFreq && found !== undefined,
};
});
const peak = data.peaks.find((p) => p.frequency === maxFreq);
return {
chartData: allHours,
peakHour: peak ?? null,
totalDays: data.totalDays,
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
Cargando datos de pico horario...
</div>
);
}
if (chartData.length === 0 || totalDays === 0) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
Sin datos de pico horario disponibles
</div>
);
}
return (
<div>
{peakHour && (
<div className="mb-4 flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
El pico más frecuente ocurre a las{" "}
<span className="font-semibold text-foreground">
{String(peakHour.hour).padStart(2, "0")}hs
</span>{" "}
({peakHour.frequency} de {totalDays} días) con un promedio de{" "}
<span className="font-semibold text-foreground">
${priceFormatter.format(peakHour.avgPeakBuy)}
</span>
</span>
</div>
)}
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
vertical={false}
/>
<XAxis
dataKey="hour"
tick={{ fontSize: 10 }}
className="text-muted-foreground"
interval={2}
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-muted-foreground"
allowDecimals={false}
label={{
value: "Frecuencia",
angle: -90,
position: "insideLeft",
offset: 10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted) / 0.3)" }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as ChartEntry;
return (
<div
className="rounded-lg border bg-card p-3 text-sm shadow-sm"
style={{ minWidth: 180 }}
>
<p className="font-semibold mb-1">{d.hour}</p>
<div className="space-y-0.5 text-muted-foreground">
<p>
Veces pico:{" "}
<span className="font-medium text-foreground">
{d.frequency}
</span>
</p>
{d.frequency > 0 && (
<>
<p>
Precio promedio:{" "}
<span className="font-medium text-foreground">
${priceFormatter.format(d.avgPeakBuy)}
</span>
</p>
<p>
Rango:{" "}
<span className="font-medium text-foreground">
${priceFormatter.format(d.minPeakBuy)} – $
{priceFormatter.format(d.maxPeakBuy)}
</span>
</p>
</>
)}
</div>
</div>
);
}}
/>
<Bar
dataKey="frequency"
name="Frecuencia"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
fill="var(--chart-2)"
/>
</BarChart>
</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-2)" }}
/>
<span className="text-xs text-muted-foreground">
Frecuencia de pico por hora
</span>
</div>
</div>
</div>
);
}

View File

@@ -45,6 +45,19 @@ export type HistoricalMinMax = {
maxBuyDate: string | null;
};
export type PeakHour = {
hour: number;
frequency: number;
avgPeakBuy: number;
minPeakBuy: number;
maxPeakBuy: number;
};
export type PeakHoursResponse = {
totalDays: number;
peaks: PeakHour[];
};
async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
@@ -109,6 +122,15 @@ export function getHistoricalMinMax(type: string): Promise<HistoricalMinMax> {
return fetcher<HistoricalMinMax>(`/api/quotes/${type}/historical/min-max`);
}
export function getPeakHours(
type: string,
startDate: string,
endDate: string,
): Promise<PeakHoursResponse> {
const params = new URLSearchParams({ startDate, endDate });
return fetcher<PeakHoursResponse>(`/api/quotes/${type}/peak-hours?${params}`);
}
export type MonthlyLast = {
month: string;
type: "BELO" | "BLUE" | "BNA";

View File

@@ -28,6 +28,7 @@ import {
getMonthlyLast,
getMonthlyPayedTotal,
getMonthlyTotals,
getPeakHours,
getPendingUpcomingExpenses,
getPeriodicExpenses,
getQuoteHistory,
@@ -63,6 +64,8 @@ export const quoteKeys = {
["quotes", type, "daily", startDate, endDate] as const,
historicalMinMax: (type: string) =>
["quotes", type, "historicalMinMax"] as const,
peakHours: (type: string, startDate: string, endDate: string) =>
["quotes", type, "peakHours", startDate, endDate] as const,
};
export function useQuotes() {
@@ -132,6 +135,18 @@ export function useHistoricalMinMax(type: string) {
});
}
export function usePeakHours(
type: string,
startDate: string,
endDate: string,
) {
return useQuery({
queryKey: quoteKeys.peakHours(type, startDate, endDate),
queryFn: () => getPeakHours(type, startDate, endDate),
staleTime: 30_000,
});
}
export function useMonthlyLast() {
return useQuery({
queryKey: ["quotes", "monthlyLast"] as const,