Claude Code Data Visualization: Build a Revenue Dashboard
Build revenue data visualization with Claude Code: Recharts, D3, CSV aggregation, accessibility, and Playwright checks.
Start With the Decision, Not the Chart
Asking Claude Code to “make a data visualization” usually produces a nice demo and a weak product feature. A useful dashboard starts with the decision it supports. For ClaudeCodeLab, page views alone are not enough. The real questions are whether readers finish articles, click internal links, open product pages, request training, or move from a free checklist to a paid template.
This guide builds a small content and monetization dashboard with React, TypeScript, Recharts, a light D3 helper, CSV aggregation, accessible labels, responsive states, and Playwright screenshot checks. Recharts is the practical default because it gives React-friendly chart components. D3 is best treated as a lower-level toolkit for scales, transformations, and custom math. Use Recharts for the dashboard, then add D3 only where the chart library becomes restrictive.
Terms matter. A data contract is the promise between your analytics export and your UI: date is an ISO date, sessions is a number, revenue is numeric USD, and channel is one of a known set. Aggregation means turning raw rows into decision-ready summaries such as revenue by channel or conversion rate. Accessibility means the chart still communicates through headings, labels, contrast, keyboard-friendly controls, and a table fallback.
Use this alongside the ClaudeCodeLab guides on analytics implementation, dashboard development, and accessibility. Keep the official references open: Claude Code docs, Recharts getting started, D3 getting started, Playwright screenshots, and WCAG non-text contrast.
Practical Use Cases
| Use case | Metrics | Best chart | Action |
|---|---|---|---|
| Article improvement | Read completion, CTA clicks, search traffic | Line and bar charts | Rewrite intros, links, and CTA placement |
| Product monetization | Product clicks, revenue, channel mix | Bar chart and share chart | Improve product cards, pricing copy, comparison tables |
| Training and consulting | Lead form completion, downloads, B2B traffic | KPI cards and funnel | Make the training path clearer |
| AdSense quality | Completion, scroll depth, ad-adjacent exits | Annotated trend chart | Protect reader experience before adding ads |
Chart choice should stay boring. Use a line chart for change over time, a bar chart for category comparison, a share chart only when categories are few, and KPI cards for numbers that need immediate attention. Avoid dual-axis charts for beginner dashboards because revenue and conversion rate can appear related when the axes are doing the persuasion.
flowchart LR
Raw["CSV / analytics events"]
Contract["data contract"]
Aggregate["aggregation"]
Chart["Recharts dashboard"]
Review["accessibility and Playwright check"]
CTA["training / products / consultation CTA"]
Raw --> Contract --> Aggregate --> Chart --> Review --> CTA
Prompt Claude Code With Constraints
Use a prompt that includes the data shape, states, and review criteria.
Build a React + TypeScript content analytics dashboard for ClaudeCodeLab.
Use Recharts for the main charts and a small D3 helper only for scale calculations.
Input data must follow a documented data contract: date, channel, sessions, signups, revenue, readRate.
Include sample data, CSV parsing, channel aggregation, loading state, error state, empty state, accessible labels, a table fallback, and responsive layout.
Avoid misleading charts: no truncated bar axis, no dual-axis chart, no color-only meaning.
Add Playwright screenshot checks for mobile and desktop.
Return copy-pasteable code and explain the failure modes to review.
Install the Libraries
npm i recharts d3
npm i -D @types/d3 @playwright/test
Define the Data Contract and Aggregate CSV
Put the contract before the chart. This prevents Claude Code from guessing field names, treating revenue as strings, or letting NaN leak into tooltips.
// dashboard-data.ts
export type Channel = "organic" | "email" | "social" | "referral" | "paid";
export type TrafficRow = {
date: string;
channel: Channel;
sessions: number;
signups: number;
revenue: number;
readRate: number;
};
export type ChannelSummary = {
channel: Channel;
sessions: number;
signups: number;
revenue: number;
conversionRate: number;
arps: number;
};
const channels: Channel[] = ["organic", "email", "social", "referral", "paid"];
export const sampleRows: TrafficRow[] = [
{ date: "2026-05-01", channel: "organic", sessions: 1280, signups: 42, revenue: 840, readRate: 0.61 },
{ date: "2026-05-01", channel: "email", sessions: 420, signups: 31, revenue: 1240, readRate: 0.74 },
{ date: "2026-05-01", channel: "social", sessions: 680, signups: 18, revenue: 260, readRate: 0.49 },
{ date: "2026-05-02", channel: "organic", sessions: 1360, signups: 48, revenue: 980, readRate: 0.64 },
{ date: "2026-05-02", channel: "referral", sessions: 310, signups: 17, revenue: 510, readRate: 0.58 },
{ date: "2026-05-02", channel: "paid", sessions: 540, signups: 22, revenue: 730, readRate: 0.52 },
];
function toNumber(value: string | undefined, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toChannel(value: string | undefined): Channel {
return channels.includes(value as Channel) ? (value as Channel) : "referral";
}
export function parseAnalyticsCsv(csv: string): TrafficRow[] {
const [headerLine, ...lines] = csv.trim().split(/\r?\n/);
if (!headerLine) return [];
const headers = headerLine.split(",").map((header) => header.trim());
return lines
.filter(Boolean)
.map((line) => {
const columns = line.split(",").map((column) => column.trim());
const get = (name: string) => columns[headers.indexOf(name)];
return {
date: get("date") ?? "",
channel: toChannel(get("channel")),
sessions: toNumber(get("sessions")),
signups: toNumber(get("signups")),
revenue: toNumber(get("revenue")),
readRate: toNumber(get("readRate")),
};
})
.filter((row) => row.date && !Number.isNaN(Date.parse(row.date)));
}
export function aggregateByChannel(rows: TrafficRow[]): ChannelSummary[] {
const map = new Map<Channel, ChannelSummary>();
for (const row of rows) {
const current =
map.get(row.channel) ??
{ channel: row.channel, sessions: 0, signups: 0, revenue: 0, conversionRate: 0, arps: 0 };
current.sessions += row.sessions;
current.signups += row.signups;
current.revenue += row.revenue;
map.set(row.channel, current);
}
return [...map.values()]
.map((row) => ({
...row,
conversionRate: row.sessions === 0 ? 0 : row.signups / row.sessions,
arps: row.sessions === 0 ? 0 : row.revenue / row.sessions,
}))
.sort((a, b) => b.revenue - a.revenue);
}
This parser is intentionally small for simple exports. If your CSV contains quoted fields, embedded commas, locale-specific decimals, or mixed time zones, ask Claude Code to switch to a dedicated parser and add fixtures for those cases.
Build the React Dashboard
The component includes KPI cards, chart tabs, loading, error, empty state, responsive containers, and a table fallback. The table is not decoration. It makes the values reviewable and gives assistive technology a reliable representation of the same data.
// ContentAnalyticsDashboard.tsx
"use client";
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { aggregateByChannel, sampleRows, type Channel, type TrafficRow } from "./dashboard-data";
type DashboardProps = {
rows?: TrafficRow[];
isLoading?: boolean;
error?: string | null;
};
type ViewMode = "revenue" | "conversion" | "mix";
const colors: Record<Channel, string> = {
organic: "#2563eb",
email: "#16a34a",
social: "#f97316",
referral: "#7c3aed",
paid: "#dc2626",
};
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const percent = new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
});
export function ContentAnalyticsDashboard({
rows = sampleRows,
isLoading = false,
error = null,
}: DashboardProps) {
const [view, setView] = useState<ViewMode>("revenue");
const summary = useMemo(() => aggregateByChannel(rows), [rows]);
const totals = useMemo(
() =>
summary.reduce(
(total, row) => ({
sessions: total.sessions + row.sessions,
signups: total.signups + row.signups,
revenue: total.revenue + row.revenue,
}),
{ sessions: 0, signups: 0, revenue: 0 },
),
[summary],
);
if (isLoading) {
return <p role="status">Loading dashboard data...</p>;
}
if (error) {
return <p role="alert">Dashboard data could not be loaded: {error}</p>;
}
if (summary.length === 0) {
return <p role="status">No data matches this date range. Try widening the filters.</p>;
}
const totalConversion = totals.sessions === 0 ? 0 : totals.signups / totals.sessions;
return (
<section aria-labelledby="content-analytics-title" style={{ display: "grid", gap: 24 }}>
<header>
<p style={{ margin: 0, color: "#64748b" }}>ClaudeCodeLab revenue dashboard</p>
<h2 id="content-analytics-title" style={{ margin: "4px 0 0" }}>
Content analytics dashboard
</h2>
</header>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))" }}>
<MetricCard label="Sessions" value={totals.sessions.toLocaleString("en-US")} />
<MetricCard label="Signups" value={totals.signups.toLocaleString("en-US")} />
<MetricCard label="Revenue" value={money.format(totals.revenue)} />
<MetricCard label="Conversion" value={percent.format(totalConversion)} />
</div>
<div role="tablist" aria-label="Chart view" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{[
["revenue", "Revenue by channel"],
["conversion", "Conversion rate"],
["mix", "Revenue mix"],
].map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={view === key}
onClick={() => setView(key as ViewMode)}
style={{
border: "1px solid #cbd5e1",
borderRadius: 8,
padding: "8px 12px",
background: view === key ? "#0f172a" : "#ffffff",
color: view === key ? "#ffffff" : "#0f172a",
}}
>
{label}
</button>
))}
</div>
<figure aria-labelledby="dashboard-chart-title" style={{ margin: 0 }}>
<h3 id="dashboard-chart-title" style={{ margin: "0 0 12px" }}>
Dashboard chart
</h3>
<ResponsiveContainer width="100%" height={320}>
{view === "revenue" ? (
<BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="channel" />
<YAxis tickFormatter={(value) => money.format(Number(value))} domain={[0, "dataMax"]} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Bar dataKey="revenue" name="Revenue">
{summary.map((row) => (
<Cell key={row.channel} fill={colors[row.channel]} />
))}
</Bar>
</BarChart>
) : view === "conversion" ? (
<BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="channel" />
<YAxis tickFormatter={(value) => percent.format(Number(value))} domain={[0, "dataMax"]} />
<Tooltip formatter={(value) => percent.format(Number(value))} />
<Bar dataKey="conversionRate" name="Conversion rate">
{summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
</Bar>
</BarChart>
) : (
<PieChart>
<Pie data={summary} dataKey="revenue" nameKey="channel" outerRadius={110} label>
{summary.map((row) => (
<Cell key={row.channel} fill={colors[row.channel]} />
))}
</Pie>
<Tooltip formatter={(value) => money.format(Number(value))} />
<Legend />
</PieChart>
)}
</ResponsiveContainer>
<figcaption style={{ color: "#64748b" }}>
Bars start at zero, values are available in the table, and color is never the only label.
</figcaption>
</figure>
<table aria-label="Channel summary" style={{ borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr>
{["Channel", "Sessions", "Signups", "Revenue", "CVR", "Revenue/session"].map((heading) => (
<th key={heading} scope="col" style={{ borderBottom: "1px solid #cbd5e1", textAlign: "left", padding: 8 }}>
{heading}
</th>
))}
</tr>
</thead>
<tbody>
{summary.map((row) => (
<tr key={row.channel}>
<th scope="row" style={{ borderBottom: "1px solid #e2e8f0", textAlign: "left", padding: 8 }}>
{row.channel}
</th>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.sessions.toLocaleString("en-US")}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.signups.toLocaleString("en-US")}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.revenue)}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{percent.format(row.conversionRate)}</td>
<td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.arps)}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div style={{ border: "1px solid #cbd5e1", borderRadius: 8, padding: 16 }}>
<p style={{ margin: 0, color: "#64748b" }}>{label}</p>
<strong style={{ display: "block", fontSize: 24, marginTop: 4 }}>{value}</strong>
</div>
);
}
Use D3 as a Helper, Not the Whole UI
For this kind of dashboard, D3 is useful when you need exact scale calculations or custom annotations. Do not move the whole React UI into D3 unless the interaction truly requires it.
// d3-scales.ts
import { extent, scaleLinear, scaleTime } from "d3";
export function buildRevenueScales(
rows: Array<{ date: string; revenue: number }>,
width: number,
height: number,
) {
const dates = rows.map((row) => new Date(row.date)).filter((date) => !Number.isNaN(date.valueOf()));
const revenues = rows.map((row) => row.revenue).filter((value) => Number.isFinite(value));
const dateExtent = extent(dates);
const revenueExtent = extent(revenues);
const minDate = dateExtent[0] ?? new Date("2026-01-01");
const maxDate = dateExtent[1] ?? minDate;
const maxRevenue = Math.max(revenueExtent[1] ?? 0, 1);
return {
x: scaleTime().domain([minDate, maxDate]).range([0, width]),
y: scaleLinear().domain([0, maxRevenue]).nice().range([height, 0]),
};
}
Pitfalls to Review
The first failure mode is a misleading axis. Bar charts should start at zero. If you truncate the Y axis, a small revenue difference can look dramatic and lead to bad CTA or ad decisions.
The second is dirty CSV data. Empty cells, localized decimals, invalid dates, and unknown channels should be handled before rendering. A chart that works only with perfect sample data is not production-ready.
The third is color-only meaning. Users need labels, legends, table values, and enough contrast. This matters for accessibility and also helps non-expert stakeholders read the dashboard correctly.
The fourth is missing states. Loading, error, and empty data are normal dashboard states, not edge cases. Claude Code should implement them in the first pass.
The fifth is mobile layout drift. A dashboard can look polished on desktop while labels overlap at 390px. Screenshot checks catch that quickly.
Add Playwright Screenshot Checks
// tests/content-analytics-dashboard.spec.ts
import { expect, test } from "@playwright/test";
test.describe("content analytics dashboard", () => {
for (const viewport of [
{ width: 390, height: 844 },
{ width: 1280, height: 900 },
]) {
test(`renders at ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/analytics-demo");
await expect(page.getByRole("heading", { name: /Content analytics dashboard/i })).toBeVisible();
await expect(page.getByRole("figure", { name: /Dashboard chart/i })).toBeVisible();
await expect(page.getByRole("table", { name: /Channel summary/i })).toBeVisible();
await expect(page).toHaveScreenshot(`content-analytics-${viewport.width}.png`, {
fullPage: true,
animations: "disabled",
});
});
}
});
npx playwright test tests/content-analytics-dashboard.spec.ts
Screenshots do not prove the math is correct, so keep unit tests for aggregation. They do catch blank charts, overlapping labels, horizontal overflow, and missing tables before a reader sees the page.
Connect the Dashboard to Revenue
Data visualization should lead to decisions. If a reader finishes an article but never visits the product library, the CTA is weak. If a consulting article gets traffic but no inquiry, the training and consultation page may need a clearer offer. If beginners read multiple tutorials, the free checklist can become the next step.
For ClaudeCodeLab-style analytics, ask Claude Code to implement the UI, event names, CSV export, review checklist, and Playwright evidence together. The chart is only valuable when it helps you decide what to rewrite, what to sell, and where to route the next reader.
What Happened When Testing This Pattern
The most useful part of this implementation was fixing the data contract before touching the chart. Claude Code produced better code once the prompt required empty states, a table fallback, mobile screenshots, and misleading-chart checks. The remaining project-specific risk is real CSV complexity: quoted fields, time zones, and provider-specific exports still need fixtures from your own analytics data.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.