Use Cases (Updated: 6/2/2026)

Claude Code Data Visualization: Build a Revenue Dashboard

Build revenue data visualization with Claude Code: Recharts, D3, CSV aggregation, accessibility, and Playwright checks.

Claude Code Data Visualization: Build a Revenue Dashboard

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 caseMetricsBest chartAction
Article improvementRead completion, CTA clicks, search trafficLine and bar chartsRewrite intros, links, and CTA placement
Product monetizationProduct clicks, revenue, channel mixBar chart and share chartImprove product cards, pricing copy, comparison tables
Training and consultingLead form completion, downloads, B2B trafficKPI cards and funnelMake the training path clearer
AdSense qualityCompletion, scroll depth, ad-adjacent exitsAnnotated trend chartProtect 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.

#Claude Code #data visualization #Recharts #D3 #dashboard #React
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.