From 0998d64cbb5b3fb70b51b99f3b8e4e01f0451335 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Fri, 28 Aug 2026 12:05:43 -0300 Subject: [PATCH] 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 --- .../modules/quotes/handlers/getPeakHours.ts | 95 +++++++++ .../src/modules/quotes/quotes.routes.ts | 2 + .../src/features/analysis/AnalysisPage.tsx | 21 ++ .../analysis/components/PeakHoursChart.tsx | 184 ++++++++++++++++++ apps/frontend/src/lib/api.ts | 22 +++ apps/frontend/src/lib/queries.ts | 15 ++ 6 files changed, 339 insertions(+) create mode 100644 apps/backend/src/modules/quotes/handlers/getPeakHours.ts create mode 100644 apps/frontend/src/features/analysis/components/PeakHoursChart.tsx diff --git a/apps/backend/src/modules/quotes/handlers/getPeakHours.ts b/apps/backend/src/modules/quotes/handlers/getPeakHours.ts new file mode 100644 index 0000000..f63d57b --- /dev/null +++ b/apps/backend/src/modules/quotes/handlers/getPeakHours.ts @@ -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>` + 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); +} diff --git a/apps/backend/src/modules/quotes/quotes.routes.ts b/apps/backend/src/modules/quotes/quotes.routes.ts index 3060051..92c450a 100644 --- a/apps/backend/src/modules/quotes/quotes.routes.ts +++ b/apps/backend/src/modules/quotes/quotes.routes.ts @@ -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); diff --git a/apps/frontend/src/features/analysis/AnalysisPage.tsx b/apps/frontend/src/features/analysis/AnalysisPage.tsx index a32d92a..3edc33c 100644 --- a/apps/frontend/src/features/analysis/AnalysisPage.tsx +++ b/apps/frontend/src/features/analysis/AnalysisPage.tsx @@ -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 }) { + + {currency === "BELO" && ( +
+

+ Análisis de pico horario +

+

+ Frecuencia con la que cada hora del día fue el momento de mayor + precio de compra dentro de cada día. +

+ +
+ )} ); } diff --git a/apps/frontend/src/features/analysis/components/PeakHoursChart.tsx b/apps/frontend/src/features/analysis/components/PeakHoursChart.tsx new file mode 100644 index 0000000..38ebae3 --- /dev/null +++ b/apps/frontend/src/features/analysis/components/PeakHoursChart.tsx @@ -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 ( +
+ Cargando datos de pico horario... +
+ ); + } + + if (chartData.length === 0 || totalDays === 0) { + return ( +
+ Sin datos de pico horario disponibles +
+ ); + } + + return ( +
+ {peakHour && ( +
+ + El pico más frecuente ocurre a las{" "} + + {String(peakHour.hour).padStart(2, "0")}hs + {" "} + ({peakHour.frequency} de {totalDays} días) con un promedio de{" "} + + ${priceFormatter.format(peakHour.avgPeakBuy)} + + +
+ )} + +
+ + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as ChartEntry; + return ( +
+

{d.hour}

+
+

+ Veces pico:{" "} + + {d.frequency} + +

+ {d.frequency > 0 && ( + <> +

+ Precio promedio:{" "} + + ${priceFormatter.format(d.avgPeakBuy)} + +

+

+ Rango:{" "} + + ${priceFormatter.format(d.minPeakBuy)} – $ + {priceFormatter.format(d.maxPeakBuy)} + +

+ + )} +
+
+ ); + }} + /> + +
+
+
+
+
+ + + Frecuencia de pico por hora + +
+
+
+ ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index ab2a28a..3d93a47 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -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(url: string): Promise { const res = await fetch(url, { credentials: "include" }); if (!res.ok) { @@ -109,6 +122,15 @@ export function getHistoricalMinMax(type: string): Promise { return fetcher(`/api/quotes/${type}/historical/min-max`); } +export function getPeakHours( + type: string, + startDate: string, + endDate: string, +): Promise { + const params = new URLSearchParams({ startDate, endDate }); + return fetcher(`/api/quotes/${type}/peak-hours?${params}`); +} + export type MonthlyLast = { month: string; type: "BELO" | "BLUE" | "BNA"; diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 1790c83..60a7faa 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -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,