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

@@ -0,0 +1,95 @@
import type { Context } from "hono";
import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getPeakHours(c: Context) {
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const type = rawType as QuoteType;
const startDateParam = c.req.query("startDate");
const endDateParam = c.req.query("endDate");
const now = new Date();
const startDate = startDateParam
? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
const cacheKey = `quotes:peak-hours:${type}:${startDate.toISOString()}:${endDate.toISOString()}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const result = await prisma.$queryRaw<
Array<{
hour: number;
frequency: number;
avg_peak_buy: number;
min_peak_buy: number;
max_peak_buy: number;
}>
>`
WITH daily_peaks AS (
SELECT
DATE("timeStamp") AS day,
EXTRACT(HOUR FROM "timeStamp")::int AS peak_hour,
buy::numeric AS peak_buy,
ROW_NUMBER() OVER (
PARTITION BY DATE("timeStamp")
ORDER BY buy::numeric DESC
) AS rn
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
)
SELECT
peak_hour AS hour,
COUNT(*)::int AS frequency,
AVG(peak_buy)::float8 AS avg_peak_buy,
MIN(peak_buy)::float8 AS min_peak_buy,
MAX(peak_buy)::float8 AS max_peak_buy
FROM daily_peaks
WHERE rn = 1
GROUP BY peak_hour
ORDER BY peak_hour ASC
`;
const totalDays = await prisma.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(DISTINCT DATE("timeStamp"))::int AS count
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
`;
const response = {
totalDays: totalDays[0]?.count ?? 0,
peaks: result.map((r) => ({
hour: r.hour,
frequency: r.frequency,
avgPeakBuy: r.avg_peak_buy,
minPeakBuy: r.min_peak_buy,
maxPeakBuy: r.max_peak_buy,
})),
};
cache.set(cacheKey, response);
return c.json(response);
}

View File

@@ -5,6 +5,7 @@ import { getDailyMinMax } from "./handlers/getDailyMinMax";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
import { getMonthlyLast } from "./handlers/getMonthlyLast";
import { getPeakHours } from "./handlers/getPeakHours";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { quoteEvents } from "./handlers/quoteEvents";
@@ -15,6 +16,7 @@ app.get("/monthly-last", getMonthlyLast);
app.get("/:type/history", getQuoteHistory);
app.get("/:type/daily", getDailyQuotes);
app.get("/:type/min-max", getDailyMinMax);
app.get("/:type/peak-hours", getPeakHours);
app.get("/:type/historical/min-max", getHistoricalMinMax);
app.get("/fetch", fetchQuotes);
app.get("/events", quoteEvents);

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,