{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "capacity-1",
  "title": "Capacity 1: runway",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "How long until each resource runs out: usage against the limit as a meter, growth per day, the days of runway written as words, the soonest first, a button to ask for more, and a plain 'Not growing' for resources that are not.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/capacity-1/capacity-1.tsx",
      "content": "// Ballmac UI: Capacity 1. https://ui.ballmac.com/blocks/capacity-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, Clock, Loader2, TriangleAlert } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Capacity1Resource = {\n  id: string\n  name: string\n  /** What it is measured in, for example \"GB\" or \"connections\". */\n  unit: string\n  used: number\n  limit: number\n  /** How much it grows by each day, in the same unit. Zero or less means it is not growing. */\n  perDay: number\n  /** Where it lives, such as \"Production database\". */\n  where?: string\n}\n\ntype Capacity1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    resources?: Capacity1Resource[]\n    title?: string\n    /** Runway of this many days or fewer is urgent. */\n    urgentDays?: number\n    /** Runway of this many days or fewer needs planning. */\n    planDays?: number\n    /** Called when someone asks for more. Reject and the message says it was not sent. */\n    onRequest?: (resource: Capacity1Resource) => void | Promise<void>\n  }\n\nconst sample: Capacity1Resource[] = [\n  { id: \"r1\", name: \"Database storage\", unit: \"GB\", used: 842, limit: 1000, perDay: 11.5, where: \"Production database\" },\n  { id: \"r2\", name: \"Object storage\", unit: \"TB\", used: 18.2, limit: 40, perDay: 0.09, where: \"User uploads\" },\n  { id: \"r3\", name: \"Database connections\", unit: \"connections\", used: 410, limit: 500, perDay: 3.2, where: \"Primary\" },\n  { id: \"r4\", name: \"Log retention\", unit: \"GB\", used: 3300, limit: 5000, perDay: 0, where: \"Observability\" },\n  { id: \"r5\", name: \"Queue throughput\", unit: \"msg/s\", used: 8900, limit: 10000, perDay: 140, where: \"Email and webhooks\" },\n]\n\ntype Level = \"urgent\" | \"plan\" | \"ok\" | \"steady\"\n/** Days until the limit is reached at the current growth, or null when it is not growing. */\nconst runway = (r: Capacity1Resource) => (r.perDay > 0 ? Math.max(0, (r.limit - r.used) / r.perDay) : null)\nconst levelOf = (r: Capacity1Resource, urgent: number, plan: number): Level => {\n  const d = runway(r)\n  return d === null ? \"steady\" : d <= urgent ? \"urgent\" : d <= plan ? \"plan\" : \"ok\"\n}\nconst num = (n: number) => (n >= 100 ? Math.round(n) : +n.toFixed(1)).toLocaleString(\"en-US\")\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3\">\n      {[0, 1, 2, 3].map((i) => (\n        <Skeleton key={i} className=\"h-28 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * How long until each resource runs out: how full it is, how fast it grows, and the days of runway left at that pace, written as words (\"Full in about 14\n * days\"). The ones that will fill soonest come first, and each has a button to ask for more. A resource that is not growing says so instead of a number.\n */\nfunction Capacity1({ resources = sample, title = \"Capacity\", urgentDays = 14, planDays = 60, onRequest, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Capacity1Props) {\n  const id = React.useId()\n  const [asked, setAsked] = React.useState<string[]>([])\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ id: string; ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && resources.length === 0 ? \"empty\" : state\n  const order: Level[] = [\"urgent\", \"plan\", \"ok\", \"steady\"]\n  const sorted = [...resources].sort((a, b) => order.indexOf(levelOf(a, urgentDays, planDays)) - order.indexOf(levelOf(b, urgentDays, planDays)) || (runway(a) ?? Infinity) - (runway(b) ?? Infinity))\n  const urgent = resources.filter((r) => levelOf(r, urgentDays, planDays) === \"urgent\").length\n\n  async function request(r: Capacity1Resource) {\n    setBusy(r.id)\n    setNote(null)\n    try {\n      await onRequest?.(r)\n      setAsked((a) => [...a, r.id])\n      setNote({ id: r.id, ok: true, text: `Requested more ${r.name.toLowerCase()}. The team will confirm.` })\n    } catch (e) {\n      setNote({ id: r.id, ok: false, text: e instanceof Error ? e.message : \"The request was not sent.\" })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <section data-slot=\"capacity-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl 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-xl text-pretty\">How full each resource is, and how many days are left at the current pace.</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading capacity\" loading={<Skel />} empty={{ title: \"No resources tracked\", description: \"Add a resource with its limit and its growth, and its runway will be worked out here.\", ...emptyState }} error={{ title: \"Capacity could not be loaded\", description: \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mb-4 text-sm\">\n          {urgent ? <><strong className=\"text-foreground\">{urgent} {urgent === 1 ? \"resource fills\" : \"resources fill\"} within {urgentDays} days.</strong> They are listed first.</> : `Nothing fills within ${urgentDays} days.`}\n        </p>\n        <ul className=\"grid gap-3\">\n          {sorted.map((r) => {\n            const lv = levelOf(r, urgentDays, planDays)\n            const days = runway(r)\n            const pct = Math.min(100, (r.used / r.limit) * 100)\n            const Icon = lv === \"urgent\" ? CircleAlert : lv === \"plan\" ? TriangleAlert : lv === \"ok\" ? Check : Clock\n            const words = days === null ? \"Not growing\" : days < 1 ? \"Full within a day\" : `Full in about ${Math.round(days)} ${Math.round(days) === 1 ? \"day\" : \"days\"}`\n            return (\n              <li key={r.id} data-level={lv} className={cn(\"bg-card rounded-xl border p-4 sm:p-5\", lv === \"urgent\" && \"border-destructive/50\")}>\n                <div className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-2\">\n                  <div className=\"min-w-0\">\n                    <p className=\"font-medium\">{r.name}</p>\n                    {r.where && <p className=\"text-muted-foreground text-xs\">{r.where}</p>}\n                  </div>\n                  <p className={cn(\"flex items-center gap-1.5 text-sm tabular-nums\", lv === \"urgent\" ? \"text-destructive font-semibold\" : lv === \"plan\" ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n                    <Icon aria-hidden=\"true\" className=\"size-4\" /> {words}\n                  </p>\n                </div>\n                <div role=\"meter\" aria-label={`${r.name} used`} aria-valuemin={0} aria-valuemax={r.limit} aria-valuenow={r.used} aria-valuetext={`${num(r.used)} of ${num(r.limit)} ${r.unit}, ${Math.round(pct)} percent. ${words}.`} className=\"bg-muted mt-3 h-2.5 overflow-hidden rounded-full\">\n                  <div className={cn(\"h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\", lv === \"urgent\" ? \"bg-destructive\" : lv === \"plan\" ? \"bg-chart-3\" : \"bg-foreground\")} style={{ width: `${pct}%` }} />\n                </div>\n                <div className=\"mt-2 flex flex-wrap items-center justify-between gap-x-6 gap-y-2\">\n                  <p className=\"text-muted-foreground text-xs tabular-nums\">{num(r.used)} of {num(r.limit)} {r.unit} · {Math.round(pct)}% used{r.perDay > 0 ? ` · grows ${num(r.perDay)} ${r.unit} a day` : \"\"}</p>\n                  {lv !== \"steady\" && lv !== \"ok\" && !asked.includes(r.id) && (\n                    <Button size=\"sm\" variant={lv === \"urgent\" ? \"default\" : \"outline\"} disabled={busy === r.id} onClick={() => void request(r)} aria-label={`Ask for more ${r.name.toLowerCase()}`}>\n                      {busy === r.id && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Ask for more\n                    </Button>\n                  )}\n                </div>\n                {note?.id === r.id && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mt-2 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              </li>\n            )\n          })}\n        </ul>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Capacity1, runway, type Capacity1Props, type Capacity1Resource }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/capacity-1/capacity-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "capacity",
      "runway",
      "forecast",
      "storage",
      "limits",
      "infrastructure",
      "planning",
      "sre"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Capacity runway. <Capacity1 resources={[{ id, name, unit, used, limit, perDay, where? }]} urgentDays={14} planDays={60} onRequest={(resource) => Promise} />. Runway = (limit - used) / perDay, shown as 'Full in about 14 days'; perDay of zero or less says 'Not growing'. Urgent and plan-soon resources come first and offer Ask for more. Exports runway.",
      "whenToUse": [
        "Planning storage, connections or throughput before they run out",
        "An infrastructure review with a clear next action"
      ],
      "whenNotToUse": [
        "Spend; use cost-1",
        "Reliability targets; use slo-1"
      ],
      "composesWith": [
        "cost-1",
        "slo-1",
        "queues-1",
        "incident-1"
      ],
      "a11y": [
        {
          "keys": "Meter",
          "action": "Each resource is a meter reading '842 of 1,000 GB, 84 percent. Full in about 14 days.'"
        },
        {
          "keys": "Runway",
          "action": "Written as words with an icon; the bar colour only reinforces it"
        },
        {
          "keys": "Order",
          "action": "The ones that fill soonest are first and the sentence above the list says how many are urgent"
        },
        {
          "keys": "Ask for more",
          "action": "A button named for the resource; the result is a polite announcement and a failure is an alert"
        }
      ],
      "customization": [
        "resources and units",
        "urgentDays and planDays",
        "onRequest",
        "runway is exported"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "capacity-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No resources tracked",
        "example": "capacity-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no resources tracked; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "capacity-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A request that fails says why."
      }
    ],
    "examples": [
      "capacity-1-demo",
      "capacity-1-calm",
      "capacity-1-loading",
      "capacity-1-empty",
      "capacity-1-error"
    ],
    "url": "https://ui.ballmac.com/components/capacity-1"
  },
  "categories": [
    "blocks",
    "capacity"
  ],
  "type": "registry:block"
}