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 { getDailyQuotes } from "./handlers/getDailyQuotes";
|
||||||
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
|
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
|
||||||
import { getMonthlyLast } from "./handlers/getMonthlyLast";
|
import { getMonthlyLast } from "./handlers/getMonthlyLast";
|
||||||
|
import { getPeakHours } from "./handlers/getPeakHours";
|
||||||
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
||||||
import { quoteEvents } from "./handlers/quoteEvents";
|
import { quoteEvents } from "./handlers/quoteEvents";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ app.get("/monthly-last", getMonthlyLast);
|
|||||||
app.get("/:type/history", getQuoteHistory);
|
app.get("/:type/history", getQuoteHistory);
|
||||||
app.get("/:type/daily", getDailyQuotes);
|
app.get("/:type/daily", getDailyQuotes);
|
||||||
app.get("/:type/min-max", getDailyMinMax);
|
app.get("/:type/min-max", getDailyMinMax);
|
||||||
|
app.get("/:type/peak-hours", getPeakHours);
|
||||||
app.get("/:type/historical/min-max", getHistoricalMinMax);
|
app.get("/:type/historical/min-max", getHistoricalMinMax);
|
||||||
app.get("/fetch", fetchQuotes);
|
app.get("/fetch", fetchQuotes);
|
||||||
app.get("/events", quoteEvents);
|
app.get("/events", quoteEvents);
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
useDailyQuotes,
|
useDailyQuotes,
|
||||||
useHistoricalMinMax,
|
useHistoricalMinMax,
|
||||||
useMonthlyLast,
|
useMonthlyLast,
|
||||||
|
usePeakHours,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
} from "@/lib/queries";
|
} from "@/lib/queries";
|
||||||
import { MonthlyVariationTable } from "./components/MonthlyVariationTable";
|
import { MonthlyVariationTable } from "./components/MonthlyVariationTable";
|
||||||
|
import { PeakHoursChart } from "./components/PeakHoursChart";
|
||||||
|
|
||||||
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
|
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
|
||||||
|
|
||||||
@@ -99,6 +101,12 @@ export function AnalysisPage({ currency }: { currency: string }) {
|
|||||||
);
|
);
|
||||||
const { data: monthlyLastData } = useMonthlyLast();
|
const { data: monthlyLastData } = useMonthlyLast();
|
||||||
|
|
||||||
|
const { data: peakHoursData, isLoading: peakHoursLoading } = usePeakHours(
|
||||||
|
"BELO",
|
||||||
|
startDateStr,
|
||||||
|
endDateStr,
|
||||||
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (!daily) return [];
|
if (!daily) return [];
|
||||||
return daily.map((d) => ({
|
return daily.map((d) => ({
|
||||||
@@ -591,6 +599,19 @@ export function AnalysisPage({ currency }: { currency: string }) {
|
|||||||
</h2>
|
</h2>
|
||||||
<MonthlyVariationTable />
|
<MonthlyVariationTable />
|
||||||
</div>
|
</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>
|
</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;
|
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> {
|
async function fetcher<T>(url: string): Promise<T> {
|
||||||
const res = await fetch(url, { credentials: "include" });
|
const res = await fetch(url, { credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -109,6 +122,15 @@ export function getHistoricalMinMax(type: string): Promise<HistoricalMinMax> {
|
|||||||
return fetcher<HistoricalMinMax>(`/api/quotes/${type}/historical/min-max`);
|
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 = {
|
export type MonthlyLast = {
|
||||||
month: string;
|
month: string;
|
||||||
type: "BELO" | "BLUE" | "BNA";
|
type: "BELO" | "BLUE" | "BNA";
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getMonthlyLast,
|
getMonthlyLast,
|
||||||
getMonthlyPayedTotal,
|
getMonthlyPayedTotal,
|
||||||
getMonthlyTotals,
|
getMonthlyTotals,
|
||||||
|
getPeakHours,
|
||||||
getPendingUpcomingExpenses,
|
getPendingUpcomingExpenses,
|
||||||
getPeriodicExpenses,
|
getPeriodicExpenses,
|
||||||
getQuoteHistory,
|
getQuoteHistory,
|
||||||
@@ -63,6 +64,8 @@ export const quoteKeys = {
|
|||||||
["quotes", type, "daily", startDate, endDate] as const,
|
["quotes", type, "daily", startDate, endDate] as const,
|
||||||
historicalMinMax: (type: string) =>
|
historicalMinMax: (type: string) =>
|
||||||
["quotes", type, "historicalMinMax"] as const,
|
["quotes", type, "historicalMinMax"] as const,
|
||||||
|
peakHours: (type: string, startDate: string, endDate: string) =>
|
||||||
|
["quotes", type, "peakHours", startDate, endDate] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useQuotes() {
|
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() {
|
export function useMonthlyLast() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["quotes", "monthlyLast"] as const,
|
queryKey: ["quotes", "monthlyLast"] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user