Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions frontend/app/components/Dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import Dashboard from "./Dashboard";
import type { Sale } from "../lib/dashboard";

// Stub the charts so the test doesn't depend on Recharts/ResponsiveContainer layout.
vi.mock("./ProductRevenueUnitsChart", () => ({
Expand All @@ -13,37 +14,44 @@ vi.mock("./ByCustomerChart", () => ({
<div data-testid="by-customer">{data.length}</div>
),
}));
vi.mock("./RevenueOverTimeChart", () => ({
default: ({ data }: { data: unknown[] }) => (
<div data-testid="over-time">{data.length}</div>
),
}));

const sale = (productName: string, customerId: string): Sale => ({
date: "2026-01-01",
customerId,
productName,
quantity: 1,
amount: 10,
});

describe("Dashboard", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("renders the charts and never warms up when initial data is present", () => {
it("derives the charts from sales and never warms up when data is present", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");

render(
<Dashboard
initialByProduct={[{ product: "A", totalAmount: 10 }]}
initialByCustomer={[{ customerId: "C1", totalAmount: 10 }]}
initialSales={[]}
/>,
);
render(<Dashboard initialSales={[sale("A", "C1"), sale("B", "C2")]} />);

expect(screen.getByTestId("by-product")).toHaveTextContent("1");
// Two distinct products / customers derived from the sales.
expect(screen.getByTestId("by-product")).toHaveTextContent("2");
expect(screen.getByTestId("by-customer")).toHaveTextContent("2");
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(fetchSpy).not.toHaveBeenCalled();
});

it("shows a warming message and triggers a refresh when starting empty", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ byProduct: [], byCustomer: [] }),
json: async () => ({ sales: [] }),
} as Response);

render(
<Dashboard initialByProduct={[]} initialByCustomer={[]} initialSales={[]} />,
);
render(<Dashboard initialSales={[]} />);

expect(await screen.findByRole("status")).toHaveTextContent(
/Calentando la demo/i,
Expand Down
103 changes: 58 additions & 45 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,64 +6,64 @@ import ByCustomerChart from "./ByCustomerChart";
import RevenueOverTimeChart from "./RevenueOverTimeChart";
import KpiCards from "./KpiCards";
import ChartCard from "./ChartCard";
import FilterBar from "./FilterBar";
import {
EMPTY_FILTERS,
computeKpis,
customerTotals,
dateRange,
filterSales,
hasActiveFilters,
productRevenueUnits,
productTotals,
revenueByDate,
salesCountByDate,
uniqueCustomers,
uniqueProducts,
type Filters,
} from "../lib/analytics";
import type {
CustomerTotal,
DashboardData,
ProductTotal,
Sale,
} from "../lib/dashboard";

type Props = {
initialByProduct: ProductTotal[];
initialByCustomer: CustomerTotal[];
initialSales: Sale[];
};
import type { DashboardData, Sale } from "../lib/dashboard";

type Props = { initialSales: Sale[] };

const POLL_INTERVAL_MS = 5000;
const MAX_ATTEMPTS = 30; // ~2.5 min, covers a free-tier mock + backend cold start

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// Wraps the dashboard and self-heals on a free-tier cold start: when we render with no data
// (the store is empty until the backend re-seeds from the sleeping mock) we re-trigger a
// refresh and poll until rows appear, instead of leaving a dead "No hay datos".
export default function Dashboard({
initialByProduct,
initialByCustomer,
initialSales,
}: Props) {
const initialEmpty =
initialByProduct.length === 0 && initialByCustomer.length === 0;

const [byProduct, setByProduct] = useState(initialByProduct);
const [byCustomer, setByCustomer] = useState(initialByCustomer);
// Owns the raw sales + filter state and derives every aggregate client-side, so the date
// range and product/customer filters recompute the whole dashboard consistently. Also
// self-heals on a free-tier cold start (re-trigger refresh + poll until rows appear).
export default function Dashboard({ initialSales }: Props) {
const initialEmpty = initialSales.length === 0;

const [sales, setSales] = useState(initialSales);
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
const [warming, setWarming] = useState(initialEmpty);
const [gaveUp, setGaveUp] = useState(false);
const running = useRef(false);

const hasData = byProduct.length > 0 || byCustomer.length > 0;
const hasData = sales.length > 0;
const active = hasActiveFilters(filters);

const range = useMemo(() => dateRange(sales), [sales]);
const productOptions = useMemo(() => uniqueProducts(sales), [sales]);
const customerOptions = useMemo(() => uniqueCustomers(sales), [sales]);

const filtered = useMemo(() => filterSales(sales, filters), [sales, filters]);
const byProduct = useMemo(() => productTotals(filtered), [filtered]);
const byCustomer = useMemo(() => customerTotals(filtered), [filtered]);
const kpis = useMemo(
() => computeKpis(sales, byProduct, byCustomer),
[sales, byProduct, byCustomer],
() => computeKpis(filtered, byProduct, byCustomer),
[filtered, byProduct, byCustomer],
);
const overTime = useMemo(() => revenueByDate(sales), [sales]);
const salesCount = useMemo(() => salesCountByDate(sales), [sales]);
const overTime = useMemo(() => revenueByDate(filtered), [filtered]);
const salesCount = useMemo(() => salesCountByDate(filtered), [filtered]);
const productData = useMemo(
() => productRevenueUnits(byProduct, sales),
[byProduct, sales],
() => productRevenueUnits(byProduct, filtered),
[byProduct, filtered],
);

// Re-triggers an ingestion each round, then checks for rows. On a cold free-tier stack the
// first refresh 502s (the mock is asleep) but *wakes* it, so a later attempt succeeds — a
// single refresh isn't enough. No synchronous state updates: every setState is post-await.
const poll = useCallback(async () => {
if (running.current) return;
running.current = true;
Expand All @@ -78,9 +78,7 @@ export default function Dashboard({
const res = await fetch("/api/dashboard", { cache: "no-store" });
if (res.ok) {
const data: DashboardData = await res.json();
if (data.byProduct.length > 0 || data.byCustomer.length > 0) {
setByProduct(data.byProduct);
setByCustomer(data.byCustomer);
if (data.sales.length > 0) {
setSales(data.sales);
setWarming(false);
running.current = false;
Expand All @@ -98,12 +96,6 @@ export default function Dashboard({
running.current = false;
}, []);

const retry = useCallback(() => {
setGaveUp(false);
setWarming(true);
poll();
}, [poll]);

useEffect(() => {
// Mount only: if SSR already delivered data we never warm up. Deferred so the async
// work starts outside the effect body (no synchronous state update on mount).
Expand All @@ -124,12 +116,33 @@ export default function Dashboard({
{gaveUp && !hasData && (
<p className="subtitle" role="alert">
La demo sigue arrancando.{" "}
<button type="button" onClick={retry}>
<button type="button" onClick={() => { setGaveUp(false); setWarming(true); poll(); }}>
Reintentar
</button>
</p>
)}

{hasData && (
<FilterBar
filters={filters}
onChange={setFilters}
onReset={() => setFilters(EMPTY_FILTERS)}
range={range}
productOptions={productOptions}
customerOptions={customerOptions}
active={active}
/>
)}

{hasData && filtered.length === 0 && (
<p className="subtitle" role="status">
No hay ventas para los filtros seleccionados.{" "}
<button type="button" onClick={() => setFilters(EMPTY_FILTERS)}>
Limpiar filtros
</button>
</p>
)}

<KpiCards
kpis={kpis}
revenueTrend={overTime.map((d) => d.total)}
Expand Down
87 changes: 87 additions & 0 deletions frontend/app/components/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import type { Filters } from "../lib/analytics";
import MultiSelect from "./MultiSelect";

type Props = {
filters: Filters;
onChange: (next: Filters) => void;
onReset: () => void;
range: { min: string | null; max: string | null };
productOptions: string[];
customerOptions: string[];
active: boolean;
};

export default function FilterBar({
filters,
onChange,
onReset,
range,
productOptions,
customerOptions,
active,
}: Props) {
return (
<section className="card filter-bar" aria-label="Filtros">
<div className="filter-field">
<label className="filter-field__label" htmlFor="filter-from">
Desde
</label>
<input
id="filter-from"
type="date"
className="filter-input"
min={range.min ?? undefined}
max={range.max ?? undefined}
value={filters.from ?? range.min ?? ""}
onChange={(e) => onChange({ ...filters, from: e.target.value || null })}
/>
</div>

<div className="filter-field">
<label className="filter-field__label" htmlFor="filter-to">
Hasta
</label>
<input
id="filter-to"
type="date"
className="filter-input"
min={range.min ?? undefined}
max={range.max ?? undefined}
value={filters.to ?? range.max ?? ""}
onChange={(e) => onChange({ ...filters, to: e.target.value || null })}
/>
</div>

<div className="filter-field">
<span className="filter-field__label">Productos</span>
<MultiSelect
label="Productos"
options={productOptions}
selected={filters.products}
onChange={(products) => onChange({ ...filters, products })}
/>
</div>

<div className="filter-field">
<span className="filter-field__label">Clientes</span>
<MultiSelect
label="Clientes"
options={customerOptions}
selected={filters.customers}
onChange={(customers) => onChange({ ...filters, customers })}
/>
</div>

<button
type="button"
className="filter-reset"
onClick={onReset}
disabled={!active}
>
Limpiar filtros
</button>
</section>
);
}
41 changes: 41 additions & 0 deletions frontend/app/components/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import MultiSelect from "./MultiSelect";

describe("MultiSelect", () => {
it("opens and toggles an option on", () => {
const onChange = vi.fn();

render(
<MultiSelect
label="Productos"
options={["A", "B", "C"]}
selected={[]}
onChange={onChange}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /Productos/i }));
fireEvent.click(screen.getByLabelText("B"));

expect(onChange).toHaveBeenCalledWith(["B"]);
});

it("clears the selection", () => {
const onChange = vi.fn();

render(
<MultiSelect
label="Clientes"
options={["A", "B"]}
selected={["A"]}
onChange={onChange}
/>,
);

fireEvent.click(screen.getByRole("button", { name: /Clientes/i }));
fireEvent.click(screen.getByRole("button", { name: /Limpiar selección/i }));

expect(onChange).toHaveBeenCalledWith([]);
});
});
Loading
Loading