diff --git a/apps/backend/src/modules/quotes/handlers/getMonthlyLast.ts b/apps/backend/src/modules/quotes/handlers/getMonthlyLast.ts new file mode 100644 index 0000000..4c952df --- /dev/null +++ b/apps/backend/src/modules/quotes/handlers/getMonthlyLast.ts @@ -0,0 +1,28 @@ +import type { Context } from "hono"; +import { cache } from "../../../lib/cache"; +import { prisma } from "../../../lib/prisma"; + +export async function getMonthlyLast(c: Context) { + const cacheKey = "quotes:monthly-last"; + const cached = cache.get(cacheKey); + if (cached) return c.json(cached); + + const rows = await prisma.$queryRaw< + Array<{ + month: string; + type: string; + buy: number; + }> + >` + SELECT + TO_CHAR(DATE_TRUNC('month', "timeStamp"), 'YYYY-MM') AS month, + "type"::text, + (ARRAY_AGG(buy ORDER BY "timeStamp" DESC))[1]::numeric::float8 AS buy + FROM "quotes_history" + GROUP BY DATE_TRUNC('month', "timeStamp"), "type" + ORDER BY month ASC + `; + + cache.set(cacheKey, rows, 300); + return c.json(rows); +} diff --git a/apps/backend/src/modules/quotes/quotes.routes.ts b/apps/backend/src/modules/quotes/quotes.routes.ts index a99a378..3060051 100644 --- a/apps/backend/src/modules/quotes/quotes.routes.ts +++ b/apps/backend/src/modules/quotes/quotes.routes.ts @@ -4,12 +4,14 @@ import { getCurrentQuotes } from "./handlers/getCurrentQuotes"; import { getDailyMinMax } from "./handlers/getDailyMinMax"; import { getDailyQuotes } from "./handlers/getDailyQuotes"; import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax"; +import { getMonthlyLast } from "./handlers/getMonthlyLast"; import { getQuoteHistory } from "./handlers/getQuoteHistory"; import { quoteEvents } from "./handlers/quoteEvents"; const app = new Hono(); app.get("/", getCurrentQuotes); +app.get("/monthly-last", getMonthlyLast); app.get("/:type/history", getQuoteHistory); app.get("/:type/daily", getDailyQuotes); app.get("/:type/min-max", getDailyMinMax); diff --git a/apps/frontend/src/features/analysis/AnalysisPage.tsx b/apps/frontend/src/features/analysis/AnalysisPage.tsx index 1539725..1d36873 100644 --- a/apps/frontend/src/features/analysis/AnalysisPage.tsx +++ b/apps/frontend/src/features/analysis/AnalysisPage.tsx @@ -14,6 +14,7 @@ import { } from "recharts"; import { DatePicker } from "@/components/ui/date-picker"; import { useDailyQuotes, useHistoricalMinMax, useQuotes } from "@/lib/queries"; +import { MonthlyVariationTable } from "./components/MonthlyVariationTable"; const CURRENCIES = ["BELO", "BLUE", "BNA"] as const; @@ -576,6 +577,13 @@ export function AnalysisPage({ currency }: { currency: string }) { )} + +
+

+ Variación mensual vs. inflación +

+ +
); } diff --git a/apps/frontend/src/features/analysis/components/MonthlyVariationTable.tsx b/apps/frontend/src/features/analysis/components/MonthlyVariationTable.tsx new file mode 100644 index 0000000..a27b8d5 --- /dev/null +++ b/apps/frontend/src/features/analysis/components/MonthlyVariationTable.tsx @@ -0,0 +1,309 @@ +import { ArrowDown, ArrowRight, ArrowUp, TrendingUp } from "lucide-react"; +import { useMemo } from "react"; +import { useInflation, useMonthlyLast } from "@/lib/queries"; + +type MonthKey = string; + +type MonthRow = { + month: MonthKey; + label: string; + BELO: number | null; + BLUE: number | null; + BNA: number | null; + inflation: number | null; +}; + +const MONTH_NAMES = [ + "Ene", + "Feb", + "Mar", + "Abr", + "May", + "Jun", + "Jul", + "Ago", + "Sep", + "Oct", + "Nov", + "Dic", +]; + +function formatMonth(monthKey: string): string { + const [y, m] = monthKey.split("-"); + return `${MONTH_NAMES[Number(m) - 1]} ${y}`; +} + +function formatPct(value: number): string { + const sign = value >= 0 ? "+" : ""; + return `${sign}${value.toFixed(1)}%`; +} + +function VariationIndicator({ + variation, + inflation, +}: { + variation: number | null; + inflation: number | null; +}) { + if (variation === null) { + return ( + + + -- + + ); + } + + if (inflation === null) { + const isPositive = variation >= 0; + return ( + + {isPositive ? ( + + ) : ( + + )} + {formatPct(variation)} + + ); + } + + const beatsInflation = variation > inflation; + return ( + + {beatsInflation ? ( + + ) : ( + + )} + {formatPct(variation)} + + ); +} + +export function MonthlyVariationTable() { + const { data: monthlyData, isLoading: monthlyLoading } = useMonthlyLast(); + const { data: inflationData, isLoading: inflationLoading } = useInflation(); + + const rows = useMemo(() => { + if (!monthlyData || monthlyData.length === 0) return []; + + const monthsMap = new Map< + MonthKey, + { BELO: number | null; BLUE: number | null; BNA: number | null } + >(); + + for (const entry of monthlyData) { + const existing = monthsMap.get(entry.month) ?? { + BELO: null, + BLUE: null, + BNA: null, + }; + if (entry.type === "BELO") existing.BELO = entry.buy; + if (entry.type === "BLUE") existing.BLUE = entry.buy; + if (entry.type === "BNA") existing.BNA = entry.buy; + monthsMap.set(entry.month, existing); + } + + const inflationMap = new Map(); + if (inflationData) { + for (const inf of inflationData) { + const monthKey = inf.fecha.slice(0, 7); + inflationMap.set(monthKey, inf.valor); + } + } + + const sortedMonths = [...monthsMap.keys()].sort(); + + const today = new Date(); + const currentMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`; + const lastMonthDate = new Date( + today.getFullYear(), + today.getMonth() - 1, + 1, + ); + const lastCompleteMonth = `${lastMonthDate.getFullYear()}-${String(lastMonthDate.getMonth() + 1).padStart(2, "0")}`; + + const calcVariation = ( + currentVal: number | null, + prevVal: number | null, + ): number | null => { + if (currentVal === null || prevVal === null || prevVal === 0) return null; + return ((currentVal - prevVal) / prevVal) * 100; + }; + + return sortedMonths + .map((month, i) => { + const prev = i > 0 ? sortedMonths[i - 1] : null; + const current = monthsMap.get(month); + const prevData = prev ? monthsMap.get(prev) : null; + + const beloVar = calcVariation( + current?.BELO ?? null, + prevData?.BELO ?? null, + ); + const blueVar = calcVariation( + current?.BLUE ?? null, + prevData?.BLUE ?? null, + ); + const bnaVar = calcVariation( + current?.BNA ?? null, + prevData?.BNA ?? null, + ); + + let inflation: number | null = null; + if (month !== currentMonth && month !== lastCompleteMonth) { + inflation = inflationMap.get(month) ?? null; + } + + return { + month, + label: formatMonth(month), + BELO: beloVar, + BLUE: blueVar, + BNA: bnaVar, + inflation, + }; + }) + .reverse(); + }, [monthlyData, inflationData]); + + const isLoading = monthlyLoading || inflationLoading; + + if (isLoading) { + return ( +
+ Cargando datos mensuales... +
+ ); + } + + if (rows.length === 0) { + return ( +
+ Sin datos mensuales disponibles +
+ ); + } + + return ( + <> +
+ {rows.map((row) => ( +
+
+ {row.label} + {row.inflation !== null ? ( + + + {formatPct(row.inflation)} inflación + + ) : ( + + Sin inflación + + )} +
+
+
+

BELO

+ +
+
+

Blue

+ +
+
+

+ Oficial +

+ +
+
+
+ ))} +
+ +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
+ Mes + + BELO + + Blue + + Oficial + + Inflación +
{row.label} + + + + + + + {row.inflation !== null ? ( + + {formatPct(row.inflation)} + + ) : ( + -- + )} +
+
+ + ); +} diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 2b7efa2..50ea4f5 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -109,6 +109,30 @@ export function getHistoricalMinMax(type: string): Promise { return fetcher(`/api/quotes/${type}/historical/min-max`); } +export type MonthlyLast = { + month: string; + type: "BELO" | "BLUE" | "BNA"; + buy: number; +}; + +export function getMonthlyLast(): Promise { + return fetcher("/api/quotes/monthly-last"); +} + +export type InflationMonth = { + fecha: string; + valor: number; +}; + +export function getInflation(): Promise { + return fetch( + "https://api.argentinadatos.com/v1/finanzas/indices/inflacion", + ).then((r) => { + if (!r.ok) throw new Error(`Inflation API error: ${r.status}`); + return r.json(); + }); +} + // Expenses export type PeriodicExpense = { diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 4bd1b9f..1790c83 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -24,6 +24,8 @@ import { getDailyQuotes, getExpenses, getHistoricalMinMax, + getInflation, + getMonthlyLast, getMonthlyPayedTotal, getMonthlyTotals, getPendingUpcomingExpenses, @@ -130,6 +132,22 @@ export function useHistoricalMinMax(type: string) { }); } +export function useMonthlyLast() { + return useQuery({ + queryKey: ["quotes", "monthlyLast"] as const, + queryFn: getMonthlyLast, + staleTime: 60_000, + }); +} + +export function useInflation() { + return useQuery({ + queryKey: ["inflation"] as const, + queryFn: getInflation, + staleTime: 86_400_000, + }); +} + // Expenses export const expenseKeys = {