feat(belo): add analysis page with historical stats, chart, and date range picker
This commit is contained in:
@@ -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<Array<{
|
||||||
|
minBuy: number;
|
||||||
|
maxBuy: number;
|
||||||
|
minBuyDate: string;
|
||||||
|
maxBuyDate: string;
|
||||||
|
}>>`
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { fetchQuotes } from "./handlers/fetchQuotes";
|
|||||||
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
import { getQuoteHistory } from "./handlers/getQuoteHistory";
|
||||||
import { getDailyQuotes } from "./handlers/getDailyQuotes";
|
import { getDailyQuotes } from "./handlers/getDailyQuotes";
|
||||||
import { getDailyMinMax } from "./handlers/getDailyMinMax";
|
import { getDailyMinMax } from "./handlers/getDailyMinMax";
|
||||||
|
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
|
||||||
import { quoteEvents } from "./handlers/quoteEvents";
|
import { quoteEvents } from "./handlers/quoteEvents";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -12,6 +13,7 @@ app.get("/", getCurrentQuotes);
|
|||||||
app.get("/:type/history", getQuoteHistory);
|
app.get("/:type/history", getQuoteHistory);
|
||||||
app.get("/:type/daily", getDailyQuotes);
|
app.get("/:type/daily", getDailyQuotes);
|
||||||
app.get("/:type/min-max", getDailyMinMax);
|
app.get("/:type/min-max", getDailyMinMax);
|
||||||
|
app.get("/:type/historical/min-max", getHistoricalMinMax);
|
||||||
app.get("/fetch", fetchQuotes);
|
app.get("/fetch", fetchQuotes);
|
||||||
app.get("/events", quoteEvents);
|
app.get("/events", quoteEvents);
|
||||||
|
|
||||||
|
|||||||
256
apps/frontend/src/features/belo/BeloAnalysisPage.tsx
Normal file
256
apps/frontend/src/features/belo/BeloAnalysisPage.tsx
Normal file
@@ -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<Date>(defaultStart);
|
||||||
|
const [endDate, setEndDate] = useState<Date>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/quotes/belo"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
BELO
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-semibold">Análisis</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Mínimo histórico (compra)</p>
|
||||||
|
{historicalLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
${priceFormatter.format(historical?.minBuy ?? 0)}
|
||||||
|
</p>
|
||||||
|
{historical?.minBuyDate && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatShortDate(historical.minBuyDate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">Máximo histórico (compra)</p>
|
||||||
|
{historicalLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
${priceFormatter.format(historical?.maxBuy ?? 0)}
|
||||||
|
</p>
|
||||||
|
{historical?.maxBuyDate && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{formatShortDate(historical.maxBuyDate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h2 className="text-sm font-medium mb-4">Evolución del precio de compra</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Desde</label>
|
||||||
|
<DatePicker value={startDate} onChange={setStartDate} placeholder="Fecha inicio" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Hasta</label>
|
||||||
|
<DatePicker value={endDate} onChange={setEndDate} placeholder="Fecha fin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dailyLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
Cargando...
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
Sin datos para este período
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
tickFormatter={(v: number) => `$${priceFormatter.format(v)}`}
|
||||||
|
width={80}
|
||||||
|
domain={yDomain}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: unknown) => [
|
||||||
|
`$${priceFormatter.format(Number(value))}`,
|
||||||
|
"Compra",
|
||||||
|
]}
|
||||||
|
labelFormatter={(label: unknown) => `Fecha: ${label}`}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={maxBuyValue}
|
||||||
|
stroke="var(--chart-1)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{
|
||||||
|
value: `Máx: $${priceFormatter.format(maxBuyValue)}`,
|
||||||
|
position: "right",
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "var(--chart-1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={minBuyValue}
|
||||||
|
stroke="var(--chart-2)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{
|
||||||
|
value: `Mín: $${priceFormatter.format(minBuyValue)}`,
|
||||||
|
position: "right",
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "var(--chart-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgBuyValue}
|
||||||
|
stroke="var(--chart-3)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{
|
||||||
|
value: `Prom: $${priceFormatter.format(avgBuyValue)}`,
|
||||||
|
position: "right",
|
||||||
|
fontSize: 11,
|
||||||
|
fill: "var(--chart-3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="buy"
|
||||||
|
name="Compra"
|
||||||
|
stroke="var(--chart-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} />
|
||||||
|
<span className="text-xs text-muted-foreground">Compra</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-1)" }} />
|
||||||
|
<span className="text-xs text-muted-foreground">Máx</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-2)" }} />
|
||||||
|
<span className="text-xs text-muted-foreground">Mín</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-3)" }} />
|
||||||
|
<span className="text-xs text-muted-foreground">Prom</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,9 +17,10 @@ import {
|
|||||||
GaugeValueText,
|
GaugeValueText,
|
||||||
} from "@/components/ui/gauge";
|
} from "@/components/ui/gauge";
|
||||||
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
|
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
import { RelativeTime } from "@/lib/time";
|
import { RelativeTime } from "@/lib/time";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { BarChart3, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
const priceFormatter = new Intl.NumberFormat("es-AR", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -228,6 +229,13 @@ export function BeloPage() {
|
|||||||
Últ. actualización: <RelativeTime date={belo.timeStamp} />
|
Últ. actualización: <RelativeTime date={belo.timeStamp} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<Link
|
||||||
|
to="/quotes/belo/analysis"
|
||||||
|
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Análisis
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export type DailyQuote = {
|
|||||||
sell: number;
|
sell: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HistoricalMinMax = {
|
||||||
|
minBuy: number;
|
||||||
|
maxBuy: number;
|
||||||
|
minBuyDate: string | null;
|
||||||
|
maxBuyDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function fetcher<T>(url: string): Promise<T> {
|
async function fetcher<T>(url: string): Promise<T> {
|
||||||
const res = await fetch(url, { credentials: "include" });
|
const res = await fetch(url, { credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -94,6 +101,10 @@ export function getDailyQuotes(
|
|||||||
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`);
|
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHistoricalMinMax(type: string): Promise<HistoricalMinMax> {
|
||||||
|
return fetcher<HistoricalMinMax>(`/api/quotes/${type}/historical/min-max`);
|
||||||
|
}
|
||||||
|
|
||||||
// Expenses
|
// Expenses
|
||||||
|
|
||||||
export type PeriodicExpense = {
|
export type PeriodicExpense = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes,
|
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes, getHistoricalMinMax,
|
||||||
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
|
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
|
||||||
updatePeriodicExpense as updatePeriodicExpenseApi,
|
updatePeriodicExpense as updatePeriodicExpenseApi,
|
||||||
deletePeriodicExpense as deletePeriodicExpenseApi,
|
deletePeriodicExpense as deletePeriodicExpenseApi,
|
||||||
@@ -23,6 +23,8 @@ export const quoteKeys = {
|
|||||||
["quotes", type, "minMax", startDate, endDate] as const,
|
["quotes", type, "minMax", startDate, endDate] as const,
|
||||||
daily: (type: string, startDate: string, endDate: string) =>
|
daily: (type: string, startDate: string, endDate: string) =>
|
||||||
["quotes", type, "daily", startDate, endDate] as const,
|
["quotes", type, "daily", startDate, endDate] as const,
|
||||||
|
historicalMinMax: (type: string) =>
|
||||||
|
["quotes", type, "historicalMinMax"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useQuotes() {
|
export function useQuotes() {
|
||||||
@@ -72,6 +74,14 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useHistoricalMinMax(type: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: quoteKeys.historicalMinMax(type),
|
||||||
|
queryFn: () => getHistoricalMinMax(type),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Expenses
|
// Expenses
|
||||||
|
|
||||||
export const expenseKeys = {
|
export const expenseKeys = {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { BeloAnalysisPage } from "@/features/belo/BeloAnalysisPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/quotes/belo/analysis")({
|
||||||
|
component: BeloAnalysisPage,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { BeloPage } from "@/features/belo/BeloPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/quotes/belo/")({
|
||||||
|
component: BeloPage,
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
import { BeloPage } from "@/features/belo/BeloPage";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/quotes/belo")({
|
export const Route = createFileRoute("/_authenticated/quotes/belo")({
|
||||||
component: BeloPage,
|
component: Outlet,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user