feat(analysis): add monthly variation table with inflation comparison

- New backend endpoint GET /api/quotes/monthly-last
- MonthlyVariationTable component with BELO/Blue/Oficial % changes
- Inflation data from ArgentinaDatos API with 24h cache
- Visual indicators: green if beats inflation, red if below
- Responsive: cards on mobile, table on desktop
This commit is contained in:
Jose Selesan
2026-08-27 10:13:56 -03:00
parent a6e95f0c05
commit 66ab56b46a
6 changed files with 389 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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 }) {
</>
)}
</div>
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Variación mensual vs. inflación
</h2>
<MonthlyVariationTable />
</div>
</div>
);
}

View File

@@ -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 (
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
<ArrowRight className="size-3" />
--
</span>
);
}
if (inflation === null) {
const isPositive = variation >= 0;
return (
<span
className={`text-xs font-medium inline-flex items-center gap-1 tabular-nums ${isPositive ? "text-emerald-500" : "text-red-500"}`}
>
{isPositive ? (
<ArrowUp className="size-3" />
) : (
<ArrowDown className="size-3" />
)}
{formatPct(variation)}
</span>
);
}
const beatsInflation = variation > inflation;
return (
<span
className={`text-xs font-medium inline-flex items-center gap-1 tabular-nums px-1.5 py-0.5 rounded ${
beatsInflation
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{beatsInflation ? (
<ArrowUp className="size-3" />
) : (
<ArrowDown className="size-3" />
)}
{formatPct(variation)}
</span>
);
}
export function MonthlyVariationTable() {
const { data: monthlyData, isLoading: monthlyLoading } = useMonthlyLast();
const { data: inflationData, isLoading: inflationLoading } = useInflation();
const rows = useMemo<MonthRow[]>(() => {
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<MonthKey, number>();
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 (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
Cargando datos mensuales...
</div>
);
}
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
Sin datos mensuales disponibles
</div>
);
}
return (
<>
<div className="space-y-2 sm:hidden">
{rows.map((row) => (
<div
key={row.month}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{row.label}</span>
{row.inflation !== null ? (
<span className="text-xs text-muted-foreground tabular-nums inline-flex items-center gap-1">
<TrendingUp className="size-3" />
{formatPct(row.inflation)} inflación
</span>
) : (
<span className="text-xs text-muted-foreground">
Sin inflación
</span>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-[10px] text-muted-foreground mb-0.5">BELO</p>
<VariationIndicator
variation={row.BELO}
inflation={row.inflation}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-0.5">Blue</p>
<VariationIndicator
variation={row.BLUE}
inflation={row.inflation}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-0.5">
Oficial
</p>
<VariationIndicator
variation={row.BNA}
inflation={row.inflation}
/>
</div>
</div>
</div>
))}
</div>
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Mes
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
BELO
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
Blue
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
Oficial
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
Inflación
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.month}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2.5 font-medium">{row.label}</td>
<td className="px-3 py-2.5 text-right">
<VariationIndicator
variation={row.BELO}
inflation={row.inflation}
/>
</td>
<td className="px-3 py-2.5 text-right">
<VariationIndicator
variation={row.BLUE}
inflation={row.inflation}
/>
</td>
<td className="px-3 py-2.5 text-right">
<VariationIndicator
variation={row.BNA}
inflation={row.inflation}
/>
</td>
<td className="px-3 py-2.5 text-right">
{row.inflation !== null ? (
<span className="text-xs text-muted-foreground tabular-nums">
{formatPct(row.inflation)}
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

View File

@@ -109,6 +109,30 @@ export function getHistoricalMinMax(type: string): Promise<HistoricalMinMax> {
return fetcher<HistoricalMinMax>(`/api/quotes/${type}/historical/min-max`);
}
export type MonthlyLast = {
month: string;
type: "BELO" | "BLUE" | "BNA";
buy: number;
};
export function getMonthlyLast(): Promise<MonthlyLast[]> {
return fetcher<MonthlyLast[]>("/api/quotes/monthly-last");
}
export type InflationMonth = {
fecha: string;
valor: number;
};
export function getInflation(): Promise<InflationMonth[]> {
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 = {

View File

@@ -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 = {