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:
95
apps/backend/src/modules/quotes/handlers/getPeakHours.ts
Normal file
95
apps/backend/src/modules/quotes/handlers/getPeakHours.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user