From 8b8a4670cbcf08654003b3b49aee9e0f2e7a6eaf Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Thu, 4 Jun 2026 09:48:20 -0300 Subject: [PATCH] feat(belo): add analysis page with historical stats, chart, and date range picker --- .../quotes/handlers/getHistoricalMinMax.ts | 37 +++ .../src/modules/quotes/quotes.routes.ts | 2 + .../src/features/belo/BeloAnalysisPage.tsx | 256 ++++++++++++++++++ apps/frontend/src/features/belo/BeloPage.tsx | 10 +- apps/frontend/src/lib/api.ts | 11 + apps/frontend/src/lib/queries.ts | 12 +- .../_authenticated/quotes.belo.analysis.tsx | 6 + .../_authenticated/quotes.belo.index.tsx | 6 + .../src/routes/_authenticated/quotes.belo.tsx | 5 +- 9 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts create mode 100644 apps/frontend/src/features/belo/BeloAnalysisPage.tsx create mode 100644 apps/frontend/src/routes/_authenticated/quotes.belo.analysis.tsx create mode 100644 apps/frontend/src/routes/_authenticated/quotes.belo.index.tsx diff --git a/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts b/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts new file mode 100644 index 0000000..aa1d6dd --- /dev/null +++ b/apps/backend/src/modules/quotes/handlers/getHistoricalMinMax.ts @@ -0,0 +1,37 @@ +import type { Context } from "hono"; +import { prisma } from "../../../lib/prisma"; +import { cache } from "../../../lib/cache"; + +const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; + +export async function getHistoricalMinMax(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 cacheKey = `quotes:historical:minmax:${rawType}`; + const cached = cache.get(cacheKey); + if (cached) return c.json(cached); + + const type = rawType as string; + + const rows = await prisma.$queryRaw>` + SELECT + (SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy", + (SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy", + (SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric ASC LIMIT 1) AS "minBuyDate", + (SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate" + `; + + const result = rows[0] ?? { minBuy: 0, maxBuy: 0, minBuyDate: null, maxBuyDate: null }; + + cache.set(cacheKey, result); + return c.json(result); +} diff --git a/apps/backend/src/modules/quotes/quotes.routes.ts b/apps/backend/src/modules/quotes/quotes.routes.ts index 00e1ac9..89828f9 100644 --- a/apps/backend/src/modules/quotes/quotes.routes.ts +++ b/apps/backend/src/modules/quotes/quotes.routes.ts @@ -4,6 +4,7 @@ import { fetchQuotes } from "./handlers/fetchQuotes"; import { getQuoteHistory } from "./handlers/getQuoteHistory"; import { getDailyQuotes } from "./handlers/getDailyQuotes"; import { getDailyMinMax } from "./handlers/getDailyMinMax"; +import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax"; import { quoteEvents } from "./handlers/quoteEvents"; const app = new Hono(); @@ -12,6 +13,7 @@ app.get("/", getCurrentQuotes); app.get("/:type/history", getQuoteHistory); app.get("/:type/daily", getDailyQuotes); app.get("/:type/min-max", getDailyMinMax); +app.get("/:type/historical/min-max", getHistoricalMinMax); app.get("/fetch", fetchQuotes); app.get("/events", quoteEvents); diff --git a/apps/frontend/src/features/belo/BeloAnalysisPage.tsx b/apps/frontend/src/features/belo/BeloAnalysisPage.tsx new file mode 100644 index 0000000..d0dc768 --- /dev/null +++ b/apps/frontend/src/features/belo/BeloAnalysisPage.tsx @@ -0,0 +1,256 @@ +import { useMemo, useState } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, +} from "recharts"; +import { subDays } from "date-fns"; +import { DatePicker } from "@/components/ui/date-picker"; +import { useHistoricalMinMax, useDailyQuotes } from "@/lib/queries"; +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; + +const priceFormatter = new Intl.NumberFormat("es-AR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function formatShortDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString("es-AR", { + day: "2-digit", + month: "2-digit", + }); +} + +export function BeloAnalysisPage() { + const today = new Date(); + const defaultStart = subDays(today, 30); + + const [startDate, setStartDate] = useState(defaultStart); + const [endDate, setEndDate] = useState(today); + + const startDateStr = formatDate(startDate); + const endDateStr = formatDate(endDate); + + const { data: historical, isLoading: historicalLoading } = useHistoricalMinMax("BELO"); + const { data: daily, isLoading: dailyLoading } = useDailyQuotes("BELO", startDateStr, endDateStr); + + const chartData = useMemo(() => { + if (!daily) return []; + return daily.map((d) => ({ + date: formatShortDate(d.date), + buy: d.buy, + })); + }, [daily]); + + const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => { + if (chartData.length === 0) return { yDomain: [0, 0] as [number, number], minBuyValue: 0, maxBuyValue: 0, avgBuyValue: 0 }; + let min = Infinity; + let max = -Infinity; + let sum = 0; + for (const d of chartData) { + if (d.buy < min) min = d.buy; + if (d.buy > max) max = d.buy; + sum += d.buy; + } + const range = max - min || 1; + return { + yDomain: [min - range * 0.1, max + range * 0.1] as [number, number], + minBuyValue: min, + maxBuyValue: max, + avgBuyValue: sum / chartData.length, + }; + }, [chartData]); + + return ( +
+
+
+ + + BELO + +

Análisis

+
+
+ +
+
+

Mínimo histórico (compra)

+ {historicalLoading ? ( +

Cargando...

+ ) : ( + <> +

+ ${priceFormatter.format(historical?.minBuy ?? 0)} +

+ {historical?.minBuyDate && ( +

+ {formatShortDate(historical.minBuyDate)} +

+ )} + + )} +
+
+

Máximo histórico (compra)

+ {historicalLoading ? ( +

Cargando...

+ ) : ( + <> +

+ ${priceFormatter.format(historical?.maxBuy ?? 0)} +

+ {historical?.maxBuyDate && ( +

+ {formatShortDate(historical.maxBuyDate)} +

+ )} + + )} +
+
+ +
+

Evolución del precio de compra

+ +
+
+ + +
+
+ + +
+
+ + {dailyLoading ? ( +
+ Cargando... +
+ ) : chartData.length === 0 ? ( +
+ Sin datos para este período +
+ ) : ( + <> +
+ + + + + `$${priceFormatter.format(v)}`} + width={80} + domain={yDomain} + /> + [ + `$${priceFormatter.format(Number(value))}`, + "Compra", + ]} + labelFormatter={(label: unknown) => `Fecha: ${label}`} + contentStyle={{ + backgroundColor: "var(--card)", + border: "1px solid var(--border)", + borderRadius: "var(--radius)", + fontSize: 13, + }} + /> + + + + + + +
+
+
+ + Compra +
+
+
+ Máx +
+
+
+ Mín +
+
+
+ Prom +
+
+ + )} +
+
+ ); +} diff --git a/apps/frontend/src/features/belo/BeloPage.tsx b/apps/frontend/src/features/belo/BeloPage.tsx index 2c678e6..fa8ec8c 100644 --- a/apps/frontend/src/features/belo/BeloPage.tsx +++ b/apps/frontend/src/features/belo/BeloPage.tsx @@ -17,9 +17,10 @@ import { GaugeValueText, } from "@/components/ui/gauge"; import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries"; +import { Link } from "@tanstack/react-router"; import { RelativeTime } from "@/lib/time"; import { cn } from "@/lib/utils"; -import { RefreshCw } from "lucide-react"; +import { BarChart3, RefreshCw } from "lucide-react"; const priceFormatter = new Intl.NumberFormat("es-AR", { minimumFractionDigits: 2, @@ -228,6 +229,13 @@ export function BeloPage() { Últ. actualización: )} + + + Análisis +