{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "charts-2",
  "title": "Charts 2: revenue report",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A revenue report: monthly recurring revenue, annual run rate, what was added last month and the largest plan, then revenue by plan as stacked columns and net revenue retention as a line against the 100% mark.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/chart-bar.json",
    "https://ui.ballmac.com/r/chart-line.json",
    "https://ui.ballmac.com/r/kpi-row.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/charts-2/charts-2.tsx",
      "content": "// Ballmac UI: Charts 2. https://ui.ballmac.com/blocks/charts-2\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, ArrowUp, CircleDollarSign } from \"lucide-react\"\n\nimport { ChartBar } from \"@/components/ballmac/chart-bar\"\nimport { ChartLine } from \"@/components/ballmac/chart-line\"\nimport { KpiItem, KpiRow } from \"@/components/ballmac/kpi-row\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\n/** One month of recurring revenue by plan, and what share of last year's revenue from the same customers is still there. */\ntype Charts2Month = { month: string; starter: number; pro: number; team: number; retention: number }\n\ntype Charts2Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    /** One row per month, oldest first, with the month written for people (\"Jan\"). At least two. */\n    months?: Charts2Month[]\n    /** ISO currency code. */\n    currency?: string\n    locale?: string\n  }\n\nconst sample: Charts2Month[] = [\n  { month: \"Nov\", starter: 12400, pro: 31800, team: 18200, retention: 101 },\n  { month: \"Dec\", starter: 12900, pro: 33600, team: 19900, retention: 102 },\n  { month: \"Jan\", starter: 13300, pro: 34900, team: 22400, retention: 100 },\n  { month: \"Feb\", starter: 13600, pro: 36800, team: 24100, retention: 103 },\n  { month: \"Mar\", starter: 14100, pro: 38900, team: 26800, retention: 105 },\n  { month: \"Apr\", starter: 14200, pro: 41200, team: 29300, retention: 104 },\n  { month: \"May\", starter: 14800, pro: 43100, team: 32600, retention: 106 },\n  { month: \"Jun\", starter: 15100, pro: 45800, team: 35400, retention: 108 },\n  { month: \"Jul\", starter: 15400, pro: 47200, team: 38900, retention: 107 },\n  { month: \"Aug\", starter: 15200, pro: 49600, team: 42100, retention: 109 },\n  { month: \"Sep\", starter: 15900, pro: 52300, team: 45800, retention: 111 },\n  { month: \"Oct\", starter: 16300, pro: 54700, team: 49600, retention: 112 },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      <Skeleton className=\"h-24 rounded-xl\" />\n      <Skeleton className=\"aspect-[2/1] w-full rounded-xl\" />\n      <Skeleton className=\"aspect-[2/1] w-full rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * A revenue report: monthly recurring revenue, annual run rate, what was added last month and the best plan, then revenue by plan as stacked columns and\n * net revenue retention as a line against the 100% mark. Every figure is worked out from the monthly rows, and each chart has a table view and a CSV download.\n */\nfunction Charts2({\n  title = \"Revenue\",\n  description = \"Recurring revenue by plan, and whether existing customers spend more or less over time.\",\n  months = sample,\n  currency = \"USD\",\n  locale = \"en\",\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Charts2Props) {\n  const id = React.useId()\n  const rows = state === \"empty\" ? [] : months\n  const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 }), [locale, currency])\n  const compact = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"currency\", currency, notation: \"compact\", maximumFractionDigits: 1 }), [locale, currency])\n  const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" }), [locale])\n  const total = (m: Charts2Month) => m.starter + m.pro + m.team\n\n  const last = rows.at(-1)\n  const prev = rows.at(-2)\n  const mrr = last ? total(last) : 0\n  const added = last && prev ? total(last) - total(prev) : null\n  const plans = [\n    { key: \"starter\", label: \"Starter\" },\n    { key: \"pro\", label: \"Pro\" },\n    { key: \"team\", label: \"Team\" },\n  ] as const\n  const top = last ? [...plans].sort((a, b) => last[b.key] - last[a.key])[0]! : null\n\n  const content = !last ? null : (\n    <div className=\"grid gap-6\">\n      <KpiRow columns={4} className=\"w-full\">\n        <KpiItem label=\"Monthly recurring revenue\" value={money.format(mrr)} detail={added === null ? undefined : <span className=\"inline-flex items-center gap-1\">{added >= 0 ? <ArrowUp aria-hidden=\"true\" className=\"size-3.5 text-chart-2\" /> : <ArrowDown aria-hidden=\"true\" className=\"text-destructive size-3.5\" />}<span className=\"text-foreground font-medium tabular-nums\">{pct.format(added / (total(prev!) || 1))}</span><span className=\"sr-only\">{added >= 0 ? \" up\" : \" down\"}</span> from {prev!.month}</span>} />\n        <KpiItem label=\"Annual run rate\" value={money.format(mrr * 12)} detail=\"Monthly revenue times twelve\" />\n        <KpiItem label={`Added in ${last.month}`} value={added === null ? \"–\" : `${added >= 0 ? \"+\" : \"−\"}${money.format(Math.abs(added))}`} detail={added === null ? \"No earlier month\" : \"Net of what customers dropped\"} />\n        <KpiItem label=\"Largest plan\" value={top!.label} detail={`${money.format(last[top!.key])} a month, ${new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0 }).format(last[top!.key] / (mrr || 1))} of the total`} />\n      </KpiRow>\n\n      <ChartBar\n        title=\"Revenue by plan\"\n        description={`Monthly recurring revenue, ${rows[0]!.month} to ${last.month}`}\n        data={rows}\n        xKey=\"month\"\n        categoryLabel=\"Month\"\n        stack=\"stacked\"\n        series={plans.map((p) => ({ key: p.key, label: p.label }))}\n        axisFormatter={(v) => compact.format(v)}\n        valueFormatter={(v) => money.format(v)}\n        locale={locale}\n        aspect=\"panorama\"\n        gap=\"tight\"\n      />\n\n      <ChartLine\n        title=\"Net revenue retention\"\n        description=\"Revenue from customers who were here a year ago, against what they paid then. Above 100% means they grow.\"\n        data={rows}\n        xKey=\"month\"\n        categoryLabel=\"Month\"\n        series={[{ key: \"retention\", label: \"Retention\", color: \"var(--chart-2)\" }]}\n        yDomain={[90, 120]}\n        yFormatter={(v) => `${v}%`}\n        valueFormatter={(v) => `${v}%`}\n        referenceLines={[{ y: 100, label: \"100%: flat\" }]}\n        locale={locale}\n        aspect=\"panorama\"\n        dots\n      />\n    </div>\n  )\n\n  return (\n    <section data-slot=\"charts-2\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-2xl text-pretty\">{description}</p>\n      </header>\n      <StateView\n        state={state === \"empty\" || !rows.length ? \"empty\" : state === \"loading\" || state === \"error\" ? state : \"ready\"}\n        onRetry={onRetry}\n        label=\"Loading revenue\"\n        loading={<Skel />}\n        empty={{ icon: <CircleDollarSign />, title: emptyState?.title ?? \"No revenue yet\", description: emptyState?.description ?? \"Once you have paying customers, revenue and retention appear here.\", action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"Revenue could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}\n      >\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Charts2, type Charts2Props, type Charts2Month }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/charts-2/charts-2.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "revenue",
      "mrr",
      "finance",
      "charts",
      "retention",
      "saas",
      "report",
      "stacked",
      "dashboard",
      "metrics"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "<Charts2 title description months={[{ month, starter, pro, team, retention }]} currency='USD' locale state />. One row per month, oldest first. Monthly recurring revenue, annual run rate, the month's net addition and the largest plan are worked out from the rows; plans are fixed to starter, pro and team in this version, so rename them in the source for your own tiers.",
      "whenToUse": [
        "A finance or SaaS revenue page",
        "Showing growth by plan next to retention"
      ],
      "whenNotToUse": [
        "Traffic and conversion (use charts-1)",
        "A pricing table (use pricing-1)"
      ],
      "composesWith": [
        "app-shell-1",
        "billing-1",
        "charts-1",
        "chart-frame"
      ],
      "a11y": [
        {
          "keys": "Change",
          "action": "Written as 'up 7.8% from Sep', not only an arrow and a color"
        },
        {
          "keys": "Each chart",
          "action": "Has a Table view of the same numbers, a CSV download and a summary written from its data"
        },
        {
          "keys": "Retention",
          "action": "A labeled reference line at 100% says what flat looks like"
        }
      ],
      "customization": [
        "currency and locale for every figure",
        "Rename the three plans in the source",
        "Swap a chart for another chart-* component"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "charts-2-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeletons shaped like the figures and the two charts, under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No revenue yet",
        "example": "charts-2-empty",
        "trigger": "state=\"empty\" (or months={[]})",
        "note": "Says revenue appears once there are paying customers; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "charts-2-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "charts-2-demo",
      "charts-2-loading",
      "charts-2-empty",
      "charts-2-error"
    ],
    "url": "https://ui.ballmac.com/components/charts-2"
  },
  "categories": [
    "blocks",
    "charts"
  ],
  "type": "registry:block"
}