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
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+
+ |
+ Mes
+ |
+
+ BELO
+ |
+
+ Blue
+ |
+
+ Oficial
+ |
+
+ Inflación
+ |
+
+
+
+ {rows.map((row) => (
+
+ | {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 = {