{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cost-1",
  "title": "Cost 1: cloud cost explorer",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Where the cloud bill goes: the total against last period and the budget in words, spend by service as a treemap, a waterfall of what changed since last period, and unusual costs with their cause to mark as looked at.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/bullet-chart.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/treemap.json",
    "https://ui.ballmac.com/r/waterfall-chart.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/cost-1/cost-1.tsx",
      "content": "// Ballmac UI: Cost 1. https://ui.ballmac.com/blocks/cost-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDownRight, ArrowUpRight, Check, Minus, TriangleAlert } from \"lucide-react\"\n\nimport { BulletChart } from \"@/components/ballmac/bullet-chart\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Treemap, type TreemapItem } from \"@/components/ballmac/treemap\"\nimport { WaterfallChart, type WaterfallStep } from \"@/components/ballmac/waterfall-chart\"\nimport { cn } from \"@/lib/utils\"\n\ntype Cost1Anomaly = {\n  id: string\n  /** What looks wrong, in a sentence. */\n  text: string\n  /** How much extra it cost, in the ledger's currency. */\n  amount: number\n  /** Where it was seen, such as \"Model inference\". */\n  service: string\n}\n\ntype Cost1Period = {\n  id: string\n  /** For example \"This month\". */\n  label: string\n  /** Spend in the period. */\n  total: number\n  /** Spend in the period before it, for the comparison. */\n  previous: number\n  /** A budget for the period, if there is one. */\n  budget?: number\n  /** Spend by service. */\n  items: TreemapItem[]\n  /** How the previous period's total became this one's: a start, the changes and an end. */\n  bridge: WaterfallStep[]\n  anomalies?: Cost1Anomaly[]\n}\n\ntype Cost1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    periods?: Cost1Period[]\n    title?: string\n    /** ISO currency code. */\n    currency?: string\n    /** The period shown at first. */\n    defaultPeriod?: string\n    /** Called when someone marks an unusual cost as looked at. Reject and it stays with the message. */\n    onAcknowledge?: (anomaly: Cost1Anomaly) => void | Promise<void>\n  }\n\nconst items = (a: [string, number, string][]): TreemapItem[] => a.map(([label, value, group]) => ({ id: label.toLowerCase().replace(/\\W+/g, \"-\"), label, value, group }))\nconst sample: Cost1Period[] = [\n  {\n    id: \"now\",\n    label: \"This month\",\n    total: 15760,\n    previous: 14120,\n    budget: 16000,\n    items: items([[\"Compute\", 4210, \"Production\"], [\"Database\", 2380, \"Production\"], [\"CDN\", 1120, \"Production\"], [\"Queues\", 340, \"Production\"], [\"Model inference\", 3150, \"AI\"], [\"Vector store\", 760, \"AI\"], [\"Embeddings\", 410, \"AI\"], [\"CI runners\", 920, \"Developer\"], [\"Logs\", 1450, \"Observability\"], [\"Metrics\", 530, \"Observability\"], [\"Traces\", 210, \"Observability\"], [\"Other\", 280, \"Other\"]]),\n    bridge: [{ id: \"s\", label: \"Last month\", value: 14120, kind: \"total\" }, { id: \"a\", label: \"Inference\", value: 1180 }, { id: \"b\", label: \"Compute\", value: 640 }, { id: \"c\", label: \"Logs\", value: 410 }, { id: \"d\", label: \"CDN\", value: -290 }, { id: \"e\", label: \"Other\", value: -300 }, { id: \"t\", label: \"This month\", value: 15760, kind: \"total\" }],\n    anomalies: [\n      { id: \"an1\", service: \"Model inference\", amount: 640, text: \"Spend on model calls jumped 38% on 14 October and has stayed there. The new assistant went live that day.\" },\n      { id: \"an2\", service: \"Logs\", amount: 210, text: \"The checkout service started logging every request body on 9 October.\" },\n    ],\n  },\n  {\n    id: \"last\",\n    label: \"Last month\",\n    total: 14120,\n    previous: 13880,\n    budget: 15000,\n    items: items([[\"Compute\", 3570, \"Production\"], [\"Database\", 2310, \"Production\"], [\"CDN\", 1410, \"Production\"], [\"Queues\", 330, \"Production\"], [\"Model inference\", 1970, \"AI\"], [\"Vector store\", 740, \"AI\"], [\"Embeddings\", 390, \"AI\"], [\"CI runners\", 880, \"Developer\"], [\"Logs\", 1040, \"Observability\"], [\"Metrics\", 510, \"Observability\"], [\"Traces\", 200, \"Observability\"], [\"Other\", 580, \"Other\"]]),\n    bridge: [{ id: \"s\", label: \"August\", value: 13880, kind: \"total\" }, { id: \"a\", label: \"Inference\", value: 420 }, { id: \"b\", label: \"Logs\", value: 150 }, { id: \"c\", label: \"Compute\", value: -210 }, { id: \"d\", label: \"Other\", value: -120 }, { id: \"t\", label: \"Last month\", value: 14120, kind: \"total\" }],\n  },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <div className=\"grid gap-4 sm:grid-cols-3\">\n        {[0, 1, 2].map((i) => (\n          <Skeleton key={i} className=\"h-24 rounded-xl\" />\n        ))}\n      </div>\n      <Skeleton className=\"aspect-[16/8] rounded-xl\" />\n      <Skeleton className=\"h-64 rounded-xl\" />\n    </div>\n  )\n}\n\n/**\n * Where the cloud bill goes: the total against last period and the budget, spend by service as a treemap, how the total moved from last period as a waterfall,\n * and anything that looks unusual with what caused it. Switch period with the control; the comparison is always written in words, never only an arrow.\n */\nfunction Cost1({ periods = sample, title = \"Cloud cost\", currency = \"USD\", defaultPeriod, onAcknowledge, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Cost1Props) {\n  const id = React.useId()\n  const [pid, setPid] = React.useState(defaultPeriod ?? periods[0]?.id)\n  const [seen, setSeen] = React.useState<string[]>([])\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const view = state === \"ready\" && periods.length === 0 ? \"empty\" : state\n  const p = periods.find((x) => x.id === pid) ?? periods[0]\n  const money = (n: number) => new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, maximumFractionDigits: 0 }).format(n)\n  const delta = p ? p.total - p.previous : 0\n  const pct = p && p.previous ? (delta / p.previous) * 100 : 0\n  const open = (p?.anomalies ?? []).filter((a) => !seen.includes(a.id))\n\n  async function acknowledge(a: Cost1Anomaly) {\n    setBusy(a.id)\n    setNote(null)\n    try {\n      await onAcknowledge?.(a)\n      setSeen((s) => [...s, a.id])\n      setNote({ ok: true, text: `Marked as looked at: ${a.service}.` })\n    } catch (e) {\n      setNote({ ok: false, text: e instanceof Error ? e.message : \"That did not go through, so it is still listed.\" })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <section data-slot=\"cost-1\" 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 flex flex-wrap items-end justify-between gap-4\">\n        <div>\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-xl text-pretty\">What the infrastructure costs, where it goes, and what changed since last time.</p>\n        </div>\n        {view === \"ready\" && periods.length > 1 && (\n          <SegmentedControl aria-label=\"Period\" value={p?.id ?? \"\"} onValueChange={(v) => setPid(v)}>\n            {periods.map((x) => (\n              <SegmentedControlItem key={x.id} value={x.id}>{x.label}</SegmentedControlItem>\n            ))}\n          </SegmentedControl>\n        )}\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading costs\" loading={<Skel />} empty={{ title: \"No cost data yet\", description: \"Connect a cloud account and its spend will be broken down here.\", ...emptyState }} error={{ title: \"Costs could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {p && (\n          <>\n            <div className=\"grid gap-4 sm:grid-cols-3\">\n              <div className=\"bg-card rounded-xl border p-4\">\n                <p className=\"text-muted-foreground text-xs\">{p.label}</p>\n                <p className=\"mt-1 text-3xl font-semibold tracking-tight tabular-nums\">{money(p.total)}</p>\n              </div>\n              <div className=\"bg-card rounded-xl border p-4\">\n                <p className=\"text-muted-foreground text-xs\">Compared with before</p>\n                <p className=\"mt-1 flex items-center gap-2 text-3xl font-semibold tracking-tight tabular-nums\">\n                  {delta > 0 ? <ArrowUpRight aria-hidden=\"true\" className=\"size-6 rtl:-scale-x-100\" /> : delta < 0 ? <ArrowDownRight aria-hidden=\"true\" className=\"size-6 rtl:-scale-x-100\" /> : <Minus aria-hidden=\"true\" className=\"size-6\" />}\n                  {Math.abs(Math.round(pct * 10) / 10)}%\n                </p>\n                <p className=\"text-muted-foreground mt-1 text-xs tabular-nums\">{delta === 0 ? \"No change\" : `${money(Math.abs(delta))} ${delta > 0 ? \"more\" : \"less\"} than the period before`}</p>\n              </div>\n              <div className=\"bg-card rounded-xl border p-4\">\n                <p className=\"text-muted-foreground text-xs\">Budget</p>\n                {p.budget ? (\n                  <>\n                    <p className=\"mt-1 text-3xl font-semibold tracking-tight tabular-nums\">{Math.round((p.total / p.budget) * 100)}%</p>\n                    <p className={cn(\"mt-1 flex items-center gap-1.5 text-xs tabular-nums\", p.total > p.budget ? \"text-destructive font-medium\" : \"text-muted-foreground\")}>\n                      {p.total > p.budget ? <TriangleAlert aria-hidden=\"true\" className=\"size-3.5\" /> : <Check aria-hidden=\"true\" className=\"size-3.5\" />}\n                      {p.total > p.budget ? `${money(p.total - p.budget)} over ${money(p.budget)}` : `${money(p.budget - p.total)} left of ${money(p.budget)}`}\n                    </p>\n                  </>\n                ) : (\n                  <p className=\"text-muted-foreground mt-2 text-sm\">No budget set.</p>\n                )}\n              </div>\n            </div>\n\n            {p.budget && (\n              <div className=\"bg-card mt-4 rounded-xl border p-4\">\n                <BulletChart items={[{ id: \"b\", label: \"Spend against budget\", value: p.total, target: p.budget, lowerIsBetter: true, max: Math.max(p.total, p.budget) * 1.15, ranges: [p.budget * 0.7, p.budget * 0.9] }]} format={money} />\n              </div>\n            )}\n\n            <h2 className=\"mt-8 mb-3 text-lg font-semibold tracking-tight\">By service</h2>\n            <Treemap items={p.items} format={money} aspect={2.2} />\n\n            <h2 className=\"mt-8 mb-3 text-lg font-semibold tracking-tight\">What changed</h2>\n            <WaterfallChart steps={p.bridge} format={(v) => money(v)} title={`Spend, ${p.bridge[0]?.label} to ${p.bridge[p.bridge.length - 1]?.label}`} />\n\n            <h2 className=\"mt-8 mb-3 text-lg font-semibold tracking-tight\">Unusual costs</h2>\n            <div aria-live=\"polite\">\n              {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mb-3 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              {open.length ? (\n                <ul className=\"grid gap-3\">\n                  {open.map((a) => (\n                    <li key={a.id} className=\"bg-card flex flex-wrap items-start justify-between gap-3 rounded-xl border p-4\">\n                      <div className=\"min-w-0\">\n                        <p className=\"flex items-center gap-2 text-sm font-medium\"><TriangleAlert aria-hidden=\"true\" className=\"text-chart-3 size-4\" /> {a.service} <span className=\"text-muted-foreground font-normal tabular-nums\">· {money(a.amount)} extra</span></p>\n                        <p className=\"text-muted-foreground mt-1 max-w-xl text-sm text-pretty\">{a.text}</p>\n                      </div>\n                      <Button size=\"sm\" variant=\"outline\" disabled={busy === a.id} onClick={() => void acknowledge(a)} aria-label={`Mark the ${a.service} cost as looked at`}>Mark as looked at</Button>\n                    </li>\n                  ))}\n                </ul>\n              ) : (\n                <p className=\"text-muted-foreground rounded-xl border border-dashed px-4 py-8 text-center text-sm\">Nothing unusual in {p.label.toLowerCase()}.</p>\n              )}\n            </div>\n          </>\n        )}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Cost1, type Cost1Anomaly, type Cost1Period, type Cost1Props }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/cost-1/cost-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "cloud cost",
      "finops",
      "spend",
      "budget",
      "treemap",
      "waterfall",
      "infrastructure",
      "anomalies"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A cloud cost explorer. <Cost1 periods={[{ id, label, total, previous, budget?, items: [{ id, label, value, group? }], bridge: [{ id, label, value, kind?: 'total' }], anomalies?: [{ id, service, amount, text }] }]} currency='USD' defaultPeriod='now' onAcknowledge={(anomaly) => Promise} />. The period control switches everything. The comparison and the budget are written in words ('$1,640 more than the period before', '$240 left of $16,000').",
      "whenToUse": [
        "A FinOps or engineering-management view of infrastructure spend",
        "Explaining why a bill moved"
      ],
      "whenNotToUse": [
        "A customer's invoice; use invoice-1",
        "Usage against a plan quota; use the usage-meter component"
      ],
      "composesWith": [
        "capacity-1",
        "slo-1",
        "dashboard-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Comparison and budget",
          "action": "Always words and an icon next to the number, never colour or an arrow alone"
        },
        {
          "keys": "Period",
          "action": "A segmented control (radio group) that updates the whole page"
        },
        {
          "keys": "Treemap and waterfall",
          "action": "Both are keyboard and screen-reader friendly components; the waterfall comes with a table"
        },
        {
          "keys": "Unusual costs",
          "action": "Each has a button named for the service; the result is a polite announcement and a failure is an alert"
        }
      ],
      "customization": [
        "periods, currency and defaultPeriod",
        "budget is optional per period",
        "onAcknowledge; reject and it stays listed"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "cost-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton for the figures, the treemap and the waterfall under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No cost data yet",
        "example": "cost-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no cost data yet; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "cost-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A failed acknowledgement keeps the cost listed and says why."
      }
    ],
    "examples": [
      "cost-1-demo",
      "cost-1-last",
      "cost-1-loading",
      "cost-1-empty",
      "cost-1-error"
    ],
    "url": "https://ui.ballmac.com/components/cost-1"
  },
  "categories": [
    "blocks",
    "cost"
  ],
  "type": "registry:block"
}