Skip to content

Commit 6ad383c

Browse files
authored
Closes #387: Implemented: build analytics dashboard (#394)
* feat: add subscription management dashboard - /subscriptions list page with template name, frequency, next run date, invoice count, USDC collected, and pause/resume/cancel actions - /subscriptions/[id] detail page with config, editable actions, calendar-style upcoming dates preview, and full invoice history table - Zustand store, subscription types, and SDK helper functions - Skeleton loading states, subscription components - 19 unit tests for SubscriptionCard and SubscriptionDetail - Navbar updated with Subscriptions link * feat: add analytics dashboard with charts, KPI cards, and CSV export - WeeklyRaisedChart (bar): USDC raised per week, last 12 weeks - StatusPieChart (pie): Invoice status breakdown - UniquePayersChart (line): Unique payers over time - FundingTimeHistogram: Funding time distribution in hours - 4 KPI cards: Total Raised, Total Released, Success Rate, Avg Funding Time - CSV export button for invoice data - Unit tests for all 5 new modules (18 tests total) - Wallet-gated access
1 parent 6dbcdb8 commit 6ad383c

11 files changed

Lines changed: 772 additions & 144 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import FundingTimeHistogram from "@/components/analytics/FundingTimeHistogram";
4+
5+
vi.mock("recharts", () => ({
6+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
7+
<div data-testid="responsive-container">{children}</div>
8+
),
9+
BarChart: ({ children }: { children: React.ReactNode }) => (
10+
<div data-testid="bar-chart">{children}</div>
11+
),
12+
Bar: () => null,
13+
XAxis: () => null,
14+
YAxis: () => null,
15+
CartesianGrid: () => null,
16+
Tooltip: () => null,
17+
}));
18+
19+
describe("FundingTimeHistogram", () => {
20+
it("renders the chart with aria-label", () => {
21+
render(
22+
<FundingTimeHistogram data={[{ label: "0-12h", count: 4 }]} />
23+
);
24+
expect(
25+
screen.getByRole("figure", { name: "Average funding time histogram" })
26+
).toBeInTheDocument();
27+
});
28+
29+
it("renders bar chart with histogram data", () => {
30+
render(
31+
<FundingTimeHistogram
32+
data={[
33+
{ label: "0-12h", count: 4 },
34+
{ label: "12-24h", count: 8 },
35+
{ label: "24-48h", count: 3 },
36+
]}
37+
/>
38+
);
39+
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
40+
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
41+
});
42+
43+
it("shows 'No data available' when data is empty", () => {
44+
render(<FundingTimeHistogram data={[]} />);
45+
expect(screen.getByText("No data available")).toBeInTheDocument();
46+
});
47+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import StatusPieChart from "@/components/analytics/StatusPieChart";
4+
5+
vi.mock("recharts", () => ({
6+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
7+
<div data-testid="responsive-container">{children}</div>
8+
),
9+
PieChart: ({ children }: { children: React.ReactNode }) => (
10+
<div data-testid="pie-chart">{children}</div>
11+
),
12+
Pie: () => null,
13+
Cell: () => null,
14+
Tooltip: () => null,
15+
Legend: () => null,
16+
}));
17+
18+
describe("StatusPieChart", () => {
19+
it("renders the chart with aria-label", () => {
20+
render(
21+
<StatusPieChart
22+
data={[
23+
{ name: "Released", value: 5 },
24+
{ name: "Refunded", value: 2 },
25+
]}
26+
/>
27+
);
28+
expect(
29+
screen.getByRole("figure", { name: "Invoice status breakdown chart" })
30+
).toBeInTheDocument();
31+
});
32+
33+
it("renders pie chart with data", () => {
34+
render(
35+
<StatusPieChart
36+
data={[
37+
{ name: "Released", value: 5 },
38+
{ name: "Refunded", value: 2 },
39+
{ name: "Active", value: 3 },
40+
]}
41+
/>
42+
);
43+
expect(screen.getByTestId("pie-chart")).toBeInTheDocument();
44+
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
45+
});
46+
47+
it("shows 'No data available' when all values are zero", () => {
48+
render(
49+
<StatusPieChart
50+
data={[
51+
{ name: "Released", value: 0 },
52+
{ name: "Refunded", value: 0 },
53+
]}
54+
/>
55+
);
56+
expect(screen.getByText("No data available")).toBeInTheDocument();
57+
});
58+
59+
it("shows 'No data available' when data is empty array", () => {
60+
render(<StatusPieChart data={[]} />);
61+
expect(screen.getByText("No data available")).toBeInTheDocument();
62+
});
63+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import UniquePayersChart from "@/components/analytics/UniquePayersChart";
4+
5+
vi.mock("recharts", () => ({
6+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
7+
<div data-testid="responsive-container">{children}</div>
8+
),
9+
LineChart: ({ children }: { children: React.ReactNode }) => (
10+
<div data-testid="line-chart">{children}</div>
11+
),
12+
Line: () => null,
13+
XAxis: () => null,
14+
YAxis: () => null,
15+
CartesianGrid: () => null,
16+
Tooltip: () => null,
17+
}));
18+
19+
describe("UniquePayersChart", () => {
20+
it("renders the chart with aria-label", () => {
21+
render(
22+
<UniquePayersChart data={[{ label: "Jan 6", count: 3 }]} />
23+
);
24+
expect(
25+
screen.getByRole("figure", { name: "Unique payers over time chart" })
26+
).toBeInTheDocument();
27+
});
28+
29+
it("renders line chart with data", () => {
30+
render(
31+
<UniquePayersChart
32+
data={[
33+
{ label: "Jan 6", count: 3 },
34+
{ label: "Jan 13", count: 7 },
35+
]}
36+
/>
37+
);
38+
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
39+
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
40+
});
41+
42+
it("shows 'No data available' when data is empty", () => {
43+
render(<UniquePayersChart data={[]} />);
44+
expect(screen.getByText("No data available")).toBeInTheDocument();
45+
});
46+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import WeeklyRaisedChart from "@/components/analytics/WeeklyRaisedChart";
4+
5+
vi.mock("recharts", () => ({
6+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
7+
<div data-testid="responsive-container">{children}</div>
8+
),
9+
BarChart: ({ children }: { children: React.ReactNode }) => (
10+
<div data-testid="bar-chart">{children}</div>
11+
),
12+
Bar: () => null,
13+
XAxis: () => null,
14+
YAxis: () => null,
15+
CartesianGrid: () => null,
16+
Tooltip: () => null,
17+
}));
18+
19+
describe("WeeklyRaisedChart", () => {
20+
it("renders the chart with aria-label", () => {
21+
render(
22+
<WeeklyRaisedChart data={[{ label: "Jan 6", amount: 150 }]} />
23+
);
24+
expect(
25+
screen.getByRole("figure", { name: "USDC raised per week chart" })
26+
).toBeInTheDocument();
27+
});
28+
29+
it("renders bar chart with data", () => {
30+
render(
31+
<WeeklyRaisedChart
32+
data={[
33+
{ label: "Jan 6", amount: 100 },
34+
{ label: "Jan 13", amount: 200 },
35+
]}
36+
/>
37+
);
38+
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
39+
expect(screen.getByTestId("responsive-container")).toBeInTheDocument();
40+
});
41+
42+
it("shows 'No data available' when data is empty", () => {
43+
render(<WeeklyRaisedChart data={[]} />);
44+
expect(screen.getByText("No data available")).toBeInTheDocument();
45+
});
46+
});

src/__tests__/csvExport.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { generateCsv, type CsvRow } from "@/lib/csvExport";
3+
4+
describe("generateCsv", () => {
5+
let clickSpy: ReturnType<typeof vi.fn>;
6+
let appendChildSpy: ReturnType<typeof vi.fn>;
7+
let removeChildSpy: ReturnType<typeof vi.fn>;
8+
let revokeObjectURLSpy: ReturnType<typeof vi.fn>;
9+
let createObjectURLSpy: ReturnType<typeof vi.fn>;
10+
let linkAttrs: Record<string, string>;
11+
12+
beforeEach(() => {
13+
clickSpy = vi.fn();
14+
appendChildSpy = vi.fn();
15+
removeChildSpy = vi.fn();
16+
revokeObjectURLSpy = vi.fn();
17+
createObjectURLSpy = vi.fn(() => "blob:mock-url");
18+
linkAttrs = {};
19+
20+
const mockLink = {
21+
click: clickSpy,
22+
setAttribute: (name: string, value: string) => {
23+
linkAttrs[name] = value;
24+
},
25+
style: { display: "" },
26+
};
27+
28+
vi.spyOn(document, "createElement").mockReturnValue(mockLink as unknown as HTMLElement);
29+
vi.spyOn(document.body, "appendChild").mockImplementation(appendChildSpy);
30+
vi.spyOn(document.body, "removeChild").mockImplementation(removeChildSpy);
31+
vi.spyOn(URL, "createObjectURL").mockImplementation(createObjectURLSpy);
32+
vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeObjectURLSpy);
33+
});
34+
35+
afterEach(() => {
36+
vi.restoreAllMocks();
37+
});
38+
39+
it("does nothing when rows are empty", () => {
40+
generateCsv([], "test");
41+
expect(appendChildSpy).not.toHaveBeenCalled();
42+
});
43+
44+
it("creates and clicks a download link", () => {
45+
const rows: CsvRow[] = [
46+
{ id: "1", status: "Released", amount: 100 },
47+
{ id: "2", status: "Pending", amount: 50 },
48+
];
49+
generateCsv(rows, "my-data");
50+
51+
expect(createObjectURLSpy).toHaveBeenCalled();
52+
expect(appendChildSpy).toHaveBeenCalled();
53+
expect(clickSpy).toHaveBeenCalled();
54+
expect(removeChildSpy).toHaveBeenCalled();
55+
expect(revokeObjectURLSpy).toHaveBeenCalled();
56+
expect(linkAttrs["download"]).toBe("my-data.csv");
57+
});
58+
59+
it("generates a Blob with CSV content", () => {
60+
const rows: CsvRow[] = [{ id: "1", name: "Test" }];
61+
generateCsv(rows, "test");
62+
63+
const blobArg = createObjectURLSpy.mock.calls[0][0] as Blob;
64+
expect(blobArg).toBeInstanceOf(Blob);
65+
});
66+
67+
it("handles values with commas by quoting them", () => {
68+
const rows: CsvRow[] = [{ id: "1", note: "hello, world" }];
69+
generateCsv(rows, "test");
70+
expect(createObjectURLSpy).toHaveBeenCalled();
71+
});
72+
73+
it("handles values with double quotes by escaping them", () => {
74+
const rows: CsvRow[] = [{ id: "1", note: 'say "hi"' }];
75+
generateCsv(rows, "test");
76+
expect(createObjectURLSpy).toHaveBeenCalled();
77+
});
78+
});

0 commit comments

Comments
 (0)