Visualisasi Data dengan Claude Code: Dashboard Revenue
Bangun visualisasi data dengan Claude Code: Recharts, D3, agregasi CSV, aksesibilitas, dan screenshot Playwright.
Mulai dari keputusan, bukan dari grafik
Meminta Claude Code “buatkan visualisasi data” biasanya menghasilkan demo yang terlihat rapi, tetapi belum tentu membantu keputusan bisnis. Dashboard yang berguna harus dimulai dari pertanyaan: siapa yang membaca angka ini dan keputusan apa yang akan dibuat? Untuk ClaudeCodeLab, pageview saja tidak cukup. Kita perlu tahu apakah pembaca menyelesaikan artikel, mengklik internal link, membuka halaman produk, mengirim form konsultasi, atau bergerak dari checklist gratis ke template berbayar.
Panduan ini membangun dashboard kecil untuk analytics konten dan monetisasi menggunakan React, TypeScript, Recharts, helper D3 kecil, agregasi CSV, label aksesibel, layout responsif, dan screenshot check dengan Playwright. Recharts cocok sebagai library komponen chart untuk React. D3 lebih tepat dipakai sebagai toolbox rendah level untuk scale, transformasi, dan kalkulasi khusus. Untuk dashboard praktis, gunakan Recharts dulu, lalu tambahkan D3 hanya saat diperlukan.
Data contract adalah kesepakatan antara sumber analytics dan UI: date berupa ISO date, sessions angka, revenue angka USD, dan channel berasal dari daftar tetap. Aggregation berarti merangkum baris mentah menjadi angka yang siap dipakai, seperti revenue per channel atau conversion rate. Aksesibilitas berarti chart tetap bisa dipahami lewat heading, tabel, kontras, dan kontrol keyboard, tidak hanya lewat warna atau mouse.
Baca juga analytics implementation, dashboard development, dan accessibility. Rujukan resmi: Claude Code docs, Recharts getting started, D3 getting started, Playwright screenshots, dan WCAG non-text contrast.
Use case nyata
| Use case | Metrik | Chart | Aksi |
|---|---|---|---|
| Optimasi artikel | Read completion, CTA click, organic traffic | Line dan bar | Perbaiki intro, internal link, CTA |
| Penjualan produk | Product click, revenue, channel mix | Bar dan share chart | Perbaiki product card, pricing, comparison table |
| Training dan konsultasi | Lead form, download, traffic B2B | KPI dan funnel | Perjelas jalur menuju konsultasi |
| Kualitas AdSense | Read depth, scroll, exit dekat iklan | Annotated trend | Jaga pengalaman baca sebelum menambah iklan |
Pilihan chart sebaiknya sederhana. Gunakan line untuk tren waktu, bar untuk perbandingan kategori, share chart hanya untuk sedikit kategori, dan KPI card untuk angka utama. Hindari dual-axis chart untuk dashboard pemula karena revenue dan conversion rate bisa terlihat berhubungan hanya karena skala sumbu.
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 untuk Claude Code
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.
Prompt ini membuat Claude Code memikirkan data shape, state gagal, aksesibilitas, dan bukti visual sejak awal.
Instalasi
npm i recharts d3
npm i -D @types/d3 @playwright/test
Data contract dan agregasi CSV
Kunci bentuk data sebelum membuat chart. Ini menghindari field yang ditebak, revenue yang dianggap string, dan NaN yang bocor ke tooltip.
// 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);
}
Parser ini untuk CSV sederhana. Jika export memiliki quoted field, koma di dalam kolom, format desimal lokal, atau timezone campur, minta Claude Code memakai parser CSV khusus dan fixture dari data asli.
Komponen dashboard React
Komponen ini memiliki KPI card, tab chart, loading/error/empty state, responsive chart, dan table fallback. Tabel membantu audit angka dan membuat data tetap terbaca untuk assistive technology.
// 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)}>
{label}
</button>
))}
</div>
<figure aria-labelledby="dashboard-chart-title" style={{ margin: 0 }}>
<h3 id="dashboard-chart-title">Dashboard chart</h3>
<ResponsiveContainer width="100%" height={320}>
{view === "revenue" ? (
<BarChart data={summary}>
<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}>
<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>Bars start at zero, values are available in the table, and color is never the only label.</figcaption>
</figure>
<table aria-label="Channel summary">
<thead>
<tr>{["Channel", "Sessions", "Signups", "Revenue", "CVR", "Revenue/session"].map((heading) => <th key={heading} scope="col">{heading}</th>)}</tr>
</thead>
<tbody>
{summary.map((row) => (
<tr key={row.channel}>
<th scope="row">{row.channel}</th>
<td>{row.sessions.toLocaleString("en-US")}</td>
<td>{row.signups.toLocaleString("en-US")}</td>
<td>{money.format(row.revenue)}</td>
<td>{percent.format(row.conversionRate)}</td>
<td>{money.format(row.arps)}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div>
<p>{label}</p>
<strong>{value}</strong>
</div>
);
}
D3 sebagai helper kecil
// 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]),
};
}
Pitfall yang harus dicek
Kesalahan pertama adalah bar chart dengan sumbu Y tidak mulai dari nol. Perbedaan kecil bisa terlihat besar dan memengaruhi keputusan CTA atau iklan. Kedua, CSV kotor: empty cell, date invalid, dan channel asing harus dibersihkan. Ketiga, makna hanya lewat warna. Keempat, tidak ada loading/error/empty state. Kelima, tidak mengecek mobile width seperti 390px.
Screenshot check dengan Playwright
// 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
Screenshot tidak membuktikan kalkulasi benar; agregasi tetap perlu unit test. Namun screenshot cepat menemukan chart kosong, label bertumpuk, overflow horizontal, dan tabel yang hilang.
Hubungkan dengan revenue path
Visualisasi data bukan dekorasi. Jika pembaca selesai membaca tetapi tidak membuka produk, CTA perlu diperbaiki. Jika artikel konsultasi punya traffic tapi tidak menghasilkan lead, halaman training dan konsultasi perlu dibuat lebih jelas. Untuk pemula, checklist gratis bisa menjadi langkah berikutnya.
Saat pola ini dicoba, hasil terbaik muncul ketika data contract ditentukan sebelum chart dibuat. Jika prompt sejak awal meminta empty state, error state, table fallback, screenshot mobile, dan review chart yang menyesatkan, Claude Code menghasilkan dashboard yang jauh lebih siap produksi. Risiko tersisa ada pada CSV nyata: quotes, timezone, dan format tiap analytics tool tetap harus diuji dengan sample asli.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Workflow Obsidian ke CLAUDE.md untuk Claude Code
Ubah catatan kerja Obsidian menjadi operating note CLAUDE.md agar konteks tidak dijelaskan ulang.
Claude Code Revenue CTA Routing: dari artikel ke PDF, Gumroad, dan konsultasi
Workflow Claude Code untuk mengarahkan pembaca ke PDF gratis, Gumroad, atau konsultasi sesuai intent.
Aturan handoff tim Claude Code: bukti review, permission, rollback, dan jalur revenue
Format handoff Claude Code untuk tim: bukti, permission rule, rollback, PDF gratis, Gumroad, dan konsultasi.