diff --git a/frontend/app/components/ByProductChart.tsx b/frontend/app/components/ByProductChart.tsx deleted file mode 100644 index 9d2f3e2..0000000 --- a/frontend/app/components/ByProductChart.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { formatAmountCompact } from "../lib/format"; -import { useChartTheme } from "../lib/theme"; -import ChartTooltip from "./ChartTooltip"; - -type Props = { - data: { product: string; totalAmount: number }[]; -}; - -export default function ByProductChart({ data }: Props) { - const theme = useChartTheme(); - - if (data.length === 0) { - return ( -

- No hay datos para mostrar. -

- ); - } - return ( - - - - - - } /> - - - - ); -} diff --git a/frontend/app/components/ChartTooltip.tsx b/frontend/app/components/ChartTooltip.tsx index 376807f..ab698ba 100644 --- a/frontend/app/components/ChartTooltip.tsx +++ b/frontend/app/components/ChartTooltip.tsx @@ -10,8 +10,8 @@ type Props = { labelFormatter?: (label: string | number) => string; }; -// Shared custom tooltip so every chart (bar, area, donut) renders the same way and picks -// up the theme via CSS variables. Recharts injects active/payload/label at runtime. +// Shared custom tooltip. Handles single-series charts (bar/area/donut) and multi-series +// ones (the composed revenue+units chart), themed via CSS variables. export default function ChartTooltip({ active, payload, @@ -20,20 +20,26 @@ export default function ChartTooltip({ }: Props) { if (!active || !payload || payload.length === 0) return null; - const entry = payload[0]; const title = label != null ? labelFormatter ? labelFormatter(label) : String(label) - : (entry.name ?? ""); + : payload.length === 1 + ? (payload[0].name ?? "") + : ""; + + const multi = payload.length > 1; return (
{title && {title}} - - {formatAmountFull(Number(entry.value ?? 0))} - + {payload.map((entry, index) => ( + + {multi && {entry.name}: } + {formatAmountFull(Number(entry.value ?? 0))} + + ))}
); } diff --git a/frontend/app/components/Dashboard.test.tsx b/frontend/app/components/Dashboard.test.tsx index 9735ed3..92ee795 100644 --- a/frontend/app/components/Dashboard.test.tsx +++ b/frontend/app/components/Dashboard.test.tsx @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import Dashboard from "./Dashboard"; // Stub the charts so the test doesn't depend on Recharts/ResponsiveContainer layout. -vi.mock("./ByProductChart", () => ({ +vi.mock("./ProductRevenueUnitsChart", () => ({ default: ({ data }: { data: unknown[] }) => (
{data.length}
), diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx index 2c89a57..c2fe277 100644 --- a/frontend/app/components/Dashboard.tsx +++ b/frontend/app/components/Dashboard.tsx @@ -1,12 +1,17 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import ByProductChart from "./ByProductChart"; +import ProductRevenueUnitsChart from "./ProductRevenueUnitsChart"; import ByCustomerChart from "./ByCustomerChart"; import RevenueOverTimeChart from "./RevenueOverTimeChart"; import KpiCards from "./KpiCards"; import ChartCard from "./ChartCard"; -import { computeKpis, revenueByDate } from "../lib/analytics"; +import { + computeKpis, + productRevenueUnits, + revenueByDate, + salesCountByDate, +} from "../lib/analytics"; import type { CustomerTotal, DashboardData, @@ -50,6 +55,11 @@ export default function Dashboard({ [sales, byProduct, byCustomer], ); const overTime = useMemo(() => revenueByDate(sales), [sales]); + const salesCount = useMemo(() => salesCountByDate(sales), [sales]); + const productData = useMemo( + () => productRevenueUnits(byProduct, sales), + [byProduct, sales], + ); // 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 @@ -120,15 +130,19 @@ export default function Dashboard({

)} - + d.total)} + salesTrend={salesCount.map((d) => d.count)} + />
- - + + diff --git a/frontend/app/components/KpiCards.test.tsx b/frontend/app/components/KpiCards.test.tsx index ec6dd9f..88789c1 100644 --- a/frontend/app/components/KpiCards.test.tsx +++ b/frontend/app/components/KpiCards.test.tsx @@ -13,13 +13,20 @@ describe("KpiCards", () => { avgTicket: 123.45, topProduct: "Café Molido", topCustomer: "C1", + distinctCustomers: 5, + distinctProducts: 6, + bestDayDate: "2026-01-06", + bestDayTotal: 257, }} + revenueTrend={[10, 20, 15]} + salesTrend={[1, 3, 2]} />, ); expect(screen.getByText("Total revenue")).toBeInTheDocument(); expect(screen.getByText("1,234.5")).toBeInTheDocument(); expect(screen.getByText("42")).toBeInTheDocument(); - expect(screen.getByText("Café Molido")).toBeInTheDocument(); + expect(screen.getByText("Customers")).toBeInTheDocument(); + expect(screen.getByText("Products")).toBeInTheDocument(); }); }); diff --git a/frontend/app/components/KpiCards.tsx b/frontend/app/components/KpiCards.tsx index ec3655c..8fa4e80 100644 --- a/frontend/app/components/KpiCards.tsx +++ b/frontend/app/components/KpiCards.tsx @@ -1,16 +1,46 @@ import type { Kpis } from "../lib/analytics"; -import { formatAmountFull, formatInt } from "../lib/format"; +import { formatAmountFull, formatDateShort, formatInt } from "../lib/format"; +import Sparkline from "./Sparkline"; -type Props = { kpis: Kpis }; +type Props = { + kpis: Kpis; + revenueTrend: number[]; + salesTrend: number[]; +}; -// Headline metrics row. Presentational: receives the precomputed KPIs. -export default function KpiCards({ kpis }: Props) { - const items: { label: string; value: string }[] = [ - { label: "Total revenue", value: formatAmountFull(kpis.totalRevenue) }, - { label: "Sales", value: formatInt(kpis.transactions) }, +type Item = { + label: string; + value: string; + sub?: string; + trend?: number[]; + color?: string; +}; + +// Headline metrics row. Presentational: receives precomputed KPIs and the daily trends +// for the two cards that show a sparkline. +export default function KpiCards({ kpis, revenueTrend, salesTrend }: Props) { + const items: Item[] = [ + { + label: "Total revenue", + value: formatAmountFull(kpis.totalRevenue), + trend: revenueTrend, + color: "var(--chart-1)", + }, + { + label: "Sales", + value: formatInt(kpis.transactions), + trend: salesTrend, + color: "var(--chart-2)", + }, { label: "Avg ticket", value: formatAmountFull(kpis.avgTicket) }, { label: "Units sold", value: formatInt(kpis.totalUnits) }, - { label: "Top product", value: kpis.topProduct ?? "—" }, + { label: "Customers", value: formatInt(kpis.distinctCustomers) }, + { label: "Products", value: formatInt(kpis.distinctProducts) }, + { + label: "Best day", + value: kpis.bestDayDate ? formatAmountFull(kpis.bestDayTotal) : "—", + sub: kpis.bestDayDate ? formatDateShort(kpis.bestDayDate) : undefined, + }, ]; return ( @@ -18,7 +48,13 @@ export default function KpiCards({ kpis }: Props) { {items.map((item) => (
{item.value} - {item.label} + + {item.label} + {item.sub && · {item.sub}} + + {item.trend && item.trend.length > 1 && ( + + )}
))} 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, }; }