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:
28
apps/backend/src/modules/quotes/handlers/getMonthlyLast.ts
Normal file
28
apps/backend/src/modules/quotes/handlers/getMonthlyLast.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user