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
61 changes: 0 additions & 61 deletions frontend/app/components/ByProductChart.tsx

This file was deleted.

20 changes: 13 additions & 7 deletions frontend/app/components/ChartTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<div className="chart-tooltip">
{title && <span className="chart-tooltip__label">{title}</span>}
<span className="chart-tooltip__value">
{formatAmountFull(Number(entry.value ?? 0))}
</span>
{payload.map((entry, index) => (
<span className="chart-tooltip__value" key={entry.name ?? index}>
{multi && <span className="chart-tooltip__name">{entry.name}: </span>}
{formatAmountFull(Number(entry.value ?? 0))}
</span>
))}
</div>
);
}
2 changes: 1 addition & 1 deletion frontend/app/components/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }) => (
<div data-testid="by-product">{data.length}</div>
),
Expand Down
24 changes: 19 additions & 5 deletions frontend/app/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -120,15 +130,19 @@ export default function Dashboard({
</p>
)}

<KpiCards kpis={kpis} />
<KpiCards
kpis={kpis}
revenueTrend={overTime.map((d) => d.total)}
salesTrend={salesCount.map((d) => d.count)}
/>

<ChartCard title="Revenue over time">
<RevenueOverTimeChart data={overTime} />
</ChartCard>

<div className="chart-grid">
<ChartCard title="Total amount by product">
<ByProductChart data={byProduct} />
<ChartCard title="Revenue & units by product">
<ProductRevenueUnitsChart data={productData} />
</ChartCard>
<ChartCard title="Total amount by customer">
<ByCustomerChart data={byCustomer} />
Expand Down
9 changes: 8 additions & 1 deletion frontend/app/components/KpiCards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
54 changes: 45 additions & 9 deletions frontend/app/components/KpiCards.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,60 @@
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 (
<section className="kpi-grid" aria-label="Key metrics">
{items.map((item) => (
<div className="kpi card" key={item.label}>
<span className="kpi__value">{item.value}</span>
<span className="kpi__label">{item.label}</span>
<span className="kpi__label">
{item.label}
{item.sub && <span className="kpi__sub"> · {item.sub}</span>}
</span>
{item.trend && item.trend.length > 1 && (
<Sparkline values={item.trend} color={item.color} />
)}
</div>
))}
</section>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import("recharts")>("recharts");
return {
Expand All @@ -19,19 +16,21 @@ vi.mock("recharts", async () => {
};
});

describe("ByProductChart", () => {
describe("ProductRevenueUnitsChart", () => {
it("renders without crashing for valid data", () => {
const { getByTestId } = render(
<ByProductChart data={[{ product: "Café Molido", totalAmount: 42 }]} />
<ProductRevenueUnitsChart data={[{ product: "P", revenue: 100, units: 5 }]} />,
);

expect(getByTestId("rc-mock")).toBeInTheDocument();
});

it("renders an empty state instead of the chart when there is no data", () => {
const { getByTestId, queryByTestId } = render(<ByProductChart data={[]} />);
const { getByTestId, queryByTestId } = render(
<ProductRevenueUnitsChart data={[]} />,
);

expect(getByTestId("empty-by-product")).toBeInTheDocument();
expect(getByTestId("empty-product")).toBeInTheDocument();
expect(queryByTestId("rc-mock")).toBeNull();
});
});
85 changes: 85 additions & 0 deletions frontend/app/components/ProductRevenueUnitsChart.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p data-testid="empty-product" className="empty-state">
No hay datos para mostrar.
</p>
);
}

return (
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 10, right: 8, bottom: 28, left: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.grid} vertical={false} />
<XAxis
dataKey="product"
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
interval={0}
angle={-15}
textAnchor="end"
height={56}
/>
<YAxis
yAxisId="revenue"
tickFormatter={formatAmountCompact}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={48}
/>
<YAxis
yAxisId="units"
orientation="right"
tickFormatter={formatInt}
tick={{ fill: theme.axis, fontSize: 12 }}
stroke={theme.border}
width={40}
/>
<Tooltip cursor={{ fill: theme.cursor }} content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12, color: theme.axis }} />
<Bar
yAxisId="revenue"
dataKey="revenue"
name="Revenue"
fill={theme.series[1]}
radius={[6, 6, 0, 0]}
maxBarSize={48}
isAnimationActive={false}
/>
<Line
yAxisId="units"
dataKey="units"
name="Units"
stroke={theme.series[3]}
strokeWidth={2}
dot={{ r: 3 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
Loading
Loading