))}
diff --git a/frontend/app/components/ByProductChart.test.tsx b/frontend/app/components/ProductRevenueUnitsChart.test.tsx
similarity index 56%
rename from frontend/app/components/ByProductChart.test.tsx
rename to frontend/app/components/ProductRevenueUnitsChart.test.tsx
index bbbcabe..ef4fcfb 100644
--- a/frontend/app/components/ByProductChart.test.tsx
+++ b/frontend/app/components/ProductRevenueUnitsChart.test.tsx
@@ -1,12 +1,9 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import ByProductChart from "./ByProductChart";
+import ProductRevenueUnitsChart from "./ProductRevenueUnitsChart";
-// Recharts' ResponsiveContainer needs real layout dimensions, which jsdom
-// does not compute. Replace it with a fixed-size passthrough so the inner
-// chart tree mounts without crashing. We don't assert on the SVG output
-// because recharts itself does not render meaningful content in jsdom —
-// instead we verify the component tree mounts and our mock is wired in.
+// Recharts' ResponsiveContainer needs real layout dimensions, which jsdom does not
+// compute. Replace it with a fixed-size passthrough so the inner chart tree mounts.
vi.mock("recharts", async () => {
const actual = await vi.importActual("recharts");
return {
@@ -19,19 +16,21 @@ vi.mock("recharts", async () => {
};
});
-describe("ByProductChart", () => {
+describe("ProductRevenueUnitsChart", () => {
it("renders without crashing for valid data", () => {
const { getByTestId } = render(
-
+ ,
);
expect(getByTestId("rc-mock")).toBeInTheDocument();
});
it("renders an empty state instead of the chart when there is no data", () => {
- const { getByTestId, queryByTestId } = render();
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
- expect(getByTestId("empty-by-product")).toBeInTheDocument();
+ expect(getByTestId("empty-product")).toBeInTheDocument();
expect(queryByTestId("rc-mock")).toBeNull();
});
});
diff --git a/frontend/app/components/ProductRevenueUnitsChart.tsx b/frontend/app/components/ProductRevenueUnitsChart.tsx
new file mode 100644
index 0000000..d386302
--- /dev/null
+++ b/frontend/app/components/ProductRevenueUnitsChart.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import {
+ Bar,
+ CartesianGrid,
+ ComposedChart,
+ Legend,
+ Line,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { formatAmountCompact, formatInt } from "../lib/format";
+import { useChartTheme } from "../lib/theme";
+import type { ProductRevenueUnits } from "../lib/analytics";
+import ChartTooltip from "./ChartTooltip";
+
+type Props = { data: ProductRevenueUnits[] };
+
+// Crosses two metrics: revenue (bars, left axis) and units sold (line, right axis), so a
+// cheap high-volume product reads differently from an expensive low-volume one.
+export default function ProductRevenueUnitsChart({ data }: Props) {
+ const theme = useChartTheme();
+
+ if (data.length === 0) {
+ return (
+
+ No hay datos para mostrar.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ } />
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/components/Sparkline.test.tsx b/frontend/app/components/Sparkline.test.tsx
new file mode 100644
index 0000000..7f83723
--- /dev/null
+++ b/frontend/app/components/Sparkline.test.tsx
@@ -0,0 +1,18 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import Sparkline from "./Sparkline";
+
+describe("Sparkline", () => {
+ it("renders an svg with a polyline for a series", () => {
+ const { container } = render();
+
+ expect(container.querySelector("svg.sparkline")).not.toBeNull();
+ expect(container.querySelector("polyline")).not.toBeNull();
+ });
+
+ it("renders nothing for fewer than two points", () => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toBeNull();
+ });
+});
diff --git a/frontend/app/components/Sparkline.tsx b/frontend/app/components/Sparkline.tsx
new file mode 100644
index 0000000..1506065
--- /dev/null
+++ b/frontend/app/components/Sparkline.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+type Props = {
+ values: number[];
+ // A CSS colour (var() works here — it's the `color` property, not an SVG attribute);
+ // the shapes use currentColor so they follow the theme.
+ color?: string;
+ height?: number;
+};
+
+// Tiny dependency-free trend line for KPI cards. Pure SVG, deterministic, no measurement.
+export default function Sparkline({
+ values,
+ color = "var(--accent)",
+ height = 32,
+}: Props) {
+ if (values.length < 2) return null;
+
+ const width = 100;
+ const max = Math.max(...values);
+ const min = Math.min(...values);
+ const range = max - min || 1;
+ const stepX = width / (values.length - 1);
+
+ const points = values
+ .map((v, i) => {
+ const x = (i * stepX).toFixed(2);
+ const y = (height - ((v - min) / range) * height).toFixed(2);
+ return `${x},${y}`;
+ })
+ .join(" ");
+
+ return (
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 5155fff..f5b2060 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -140,6 +140,19 @@ main {
letter-spacing: 0.04em;
}
+.kpi__sub {
+ text-transform: none;
+ letter-spacing: 0;
+ opacity: 0.8;
+}
+
+.sparkline {
+ display: block;
+ width: 100%;
+ height: 32px;
+ margin-top: 0.5rem;
+}
+
/* Chart grid ------------------------------------------------------------- */
.chart-grid {
display: grid;
diff --git a/frontend/app/lib/analytics.test.ts b/frontend/app/lib/analytics.test.ts
index 5fd991c..79dcdcc 100644
--- a/frontend/app/lib/analytics.test.ts
+++ b/frontend/app/lib/analytics.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { computeKpis, revenueByDate } from "./analytics";
+import {
+ computeKpis,
+ productRevenueUnits,
+ revenueByDate,
+ salesCountByDate,
+} from "./analytics";
import type { Sale } from "./dashboard";
const sale = (date: string, amount: number, quantity = 1): Sale => ({
@@ -54,5 +59,60 @@ describe("computeKpis", () => {
expect(kpis.avgTicket).toBe(0);
expect(kpis.topProduct).toBeNull();
expect(kpis.topCustomer).toBeNull();
+ expect(kpis.distinctCustomers).toBe(0);
+ expect(kpis.bestDayDate).toBeNull();
+ });
+
+ it("counts distinct customers/products and finds the best day", () => {
+ const sales: Sale[] = [
+ { date: "2026-01-01", customerId: "C1", productName: "A", quantity: 1, amount: 30 },
+ { date: "2026-01-01", customerId: "C2", productName: "B", quantity: 1, amount: 20 },
+ { date: "2026-01-02", customerId: "C1", productName: "A", quantity: 1, amount: 90 },
+ ];
+
+ const kpis = computeKpis(sales, [], []);
+
+ expect(kpis.distinctCustomers).toBe(2);
+ expect(kpis.distinctProducts).toBe(2);
+ expect(kpis.bestDayDate).toBe("2026-01-02");
+ expect(kpis.bestDayTotal).toBe(90);
+ });
+});
+
+describe("salesCountByDate", () => {
+ it("counts sales per day, sorted ascending", () => {
+ const result = salesCountByDate([
+ { date: "2026-01-02", customerId: "C1", productName: "A", quantity: 1, amount: 10 },
+ { date: "2026-01-01", customerId: "C1", productName: "A", quantity: 1, amount: 10 },
+ { date: "2026-01-02", customerId: "C2", productName: "B", quantity: 1, amount: 10 },
+ ]);
+
+ expect(result).toEqual([
+ { date: "2026-01-01", count: 1 },
+ { date: "2026-01-02", count: 2 },
+ ]);
+ });
+});
+
+describe("productRevenueUnits", () => {
+ it("joins backend revenue with units summed from raw sales, keeping order", () => {
+ const sales: Sale[] = [
+ { date: "2026-01-01", customerId: "C1", productName: "A", quantity: 3, amount: 60 },
+ { date: "2026-01-02", customerId: "C2", productName: "A", quantity: 2, amount: 40 },
+ { date: "2026-01-02", customerId: "C2", productName: "B", quantity: 5, amount: 50 },
+ ];
+
+ const result = productRevenueUnits(
+ [
+ { product: "A", totalAmount: 100 },
+ { product: "B", totalAmount: 50 },
+ ],
+ sales,
+ );
+
+ expect(result).toEqual([
+ { product: "A", revenue: 100, units: 5 },
+ { product: "B", revenue: 50, units: 5 },
+ ]);
});
});
diff --git a/frontend/app/lib/analytics.ts b/frontend/app/lib/analytics.ts
index 95974ee..72a0ca3 100644
--- a/frontend/app/lib/analytics.ts
+++ b/frontend/app/lib/analytics.ts
@@ -1,6 +1,12 @@
import type { CustomerTotal, ProductTotal, Sale } from "./dashboard";
export type DailyRevenue = { date: string; total: number };
+export type DailyCount = { date: string; count: number };
+export type ProductRevenueUnits = {
+ product: string;
+ revenue: number;
+ units: number;
+};
export type Kpis = {
totalRevenue: number;
@@ -9,6 +15,10 @@ export type Kpis = {
avgTicket: number;
topProduct: string | null;
topCustomer: string | null;
+ distinctCustomers: number;
+ distinctProducts: number;
+ bestDayDate: string | null;
+ bestDayTotal: number;
};
// Revenue aggregated by day, sorted ascending — the backend has no time aggregation,
@@ -23,6 +33,34 @@ export function revenueByDate(sales: Sale[]): DailyRevenue[] {
.sort((a, b) => a.date.localeCompare(b.date));
}
+// Number of sales per day, sorted ascending — feeds the "Sales" sparkline.
+export function salesCountByDate(sales: Sale[]): DailyCount[] {
+ const counts = new Map();
+ for (const sale of sales) {
+ counts.set(sale.date, (counts.get(sale.date) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .map(([date, count]) => ({ date, count }))
+ .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+// Revenue (from the backend aggregate) joined with units sold (summed from raw sales),
+// preserving the backend's revenue-desc order. Powers the revenue+units composed chart.
+export function productRevenueUnits(
+ byProduct: ProductTotal[],
+ sales: Sale[],
+): ProductRevenueUnits[] {
+ const units = new Map();
+ for (const sale of sales) {
+ units.set(sale.productName, (units.get(sale.productName) ?? 0) + sale.quantity);
+ }
+ return byProduct.map((p) => ({
+ product: p.product,
+ revenue: p.totalAmount,
+ units: units.get(p.product) ?? 0,
+ }));
+}
+
// Headline numbers for the KPI row. byProduct/byCustomer arrive already sorted desc
// from the backend, so their first element is the top performer.
export function computeKpis(
@@ -33,6 +71,13 @@ export function computeKpis(
const totalRevenue = sales.reduce((sum, s) => sum + s.amount, 0);
const totalUnits = sales.reduce((sum, s) => sum + s.quantity, 0);
const transactions = sales.length;
+
+ const daily = revenueByDate(sales);
+ const best = daily.reduce(
+ (max, day) => (day.total > max.total ? day : max),
+ { date: "", total: -Infinity },
+ );
+
return {
totalRevenue,
totalUnits,
@@ -40,5 +85,9 @@ export function computeKpis(
avgTicket: transactions > 0 ? totalRevenue / transactions : 0,
topProduct: byProduct[0]?.product ?? null,
topCustomer: byCustomer[0]?.customerId ?? null,
+ distinctCustomers: new Set(sales.map((s) => s.customerId)).size,
+ distinctProducts: new Set(sales.map((s) => s.productName)).size,
+ bestDayDate: daily.length > 0 ? best.date : null,
+ bestDayTotal: daily.length > 0 ? best.total : 0,
};
}