{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "goals-1",
  "title": "Goals 1: objectives and key results",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Objectives and key results for a period: progress worked out from start, current and target, a pace mark on each bar for where it should be today, on track, at risk or behind in words, overall progress, team filter and in-place updates.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/goals-1/goals-1.tsx",
      "content": "// Ballmac UI: Goals 1. https://ui.ballmac.com/blocks/goals-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, CircleCheck, ClockAlert, Loader2, Pencil, Target, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Goals1KeyResult = {\n  id: string\n  title: string\n  /** Where it started, where it should end, and where it is now. Target below start means lower is better. */\n  start: number\n  target: number\n  current: number\n  /** \"%\", \"users\", \"$\", shown after the numbers. Use prefix for a leading symbol. */\n  unit?: string\n  prefix?: string\n}\n\ntype Goals1Objective = { id: string; title: string; owner: string; team: string; keyResults: Goals1KeyResult[] }\n\ntype Goals1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    objectives?: Goals1Objective[]\n    title?: string\n    /** \"Q4 2026\". */\n    period?: string\n    /** ISO dates the period starts and ends. */\n    range?: [string, string]\n    /** An ISO date for \"today\", used for pace. The block never reads the clock. */\n    today?: string\n    locale?: string\n    /** Called with a new current value. Return a promise; reject and the old value comes back with a message. */\n    onUpdate?: (objective: Goals1Objective, keyResult: Goals1KeyResult, value: number) => void | Promise<void>\n  }\n\nconst sample: Goals1Objective[] = [\n  {\n    id: \"o1\", title: \"Make onboarding something people finish\", owner: \"Priya Shah\", team: \"Product\",\n    keyResults: [\n      { id: \"k1\", title: \"Raise setup completion\", start: 48, target: 80, current: 71, unit: \"%\" },\n      { id: \"k2\", title: \"Cut time to first project\", start: 22, target: 8, current: 11, unit: \" min\" },\n      { id: \"k3\", title: \"Teams invited in week one\", start: 1200, target: 3000, current: 2640 },\n    ],\n  },\n  {\n    id: \"o2\", title: \"Grow revenue from existing customers\", owner: \"Sam Okafor\", team: \"Growth\",\n    keyResults: [\n      { id: \"k4\", title: \"Expansion revenue\", start: 0, target: 400, current: 150, unit: \"k\", prefix: \"$\" },\n      { id: \"k5\", title: \"Customers on yearly plans\", start: 31, target: 50, current: 36, unit: \"%\" },\n      { id: \"k6\", title: \"Reduce monthly churn\", start: 3.4, target: 2, current: 3.1, unit: \"%\" },\n    ],\n  },\n  {\n    id: \"o3\", title: \"Be the fastest product in our category\", owner: \"Theo Nakamura\", team: \"Engineering\",\n    keyResults: [\n      { id: \"k7\", title: \"Median page load\", start: 2400, target: 900, current: 1050, unit: \" ms\" },\n      { id: \"k8\", title: \"Errors per 1,000 sessions\", start: 14, target: 3, current: 4, unit: \"\" },\n    ],\n  },\n]\n\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\nconst clamp = (n: number) => Math.max(0, Math.min(1, n))\nconst frac = (k: Goals1KeyResult) => (k.target === k.start ? (k.current >= k.target ? 1 : 0) : clamp((k.current - k.start) / (k.target - k.start)))\n\nconst statusMeta = {\n  done: { label: \"Done\", icon: CircleCheck, chip: \"bg-chart-2/15 border-chart-2/30\" },\n  track: { label: \"On track\", icon: CircleCheck, chip: \"bg-chart-2/10 border-chart-2/30\" },\n  risk: { label: \"At risk\", icon: ClockAlert, chip: \"bg-amber-500/15 border-amber-500/40\" },\n  behind: { label: \"Behind\", icon: CircleAlert, chip: \"bg-destructive/10 border-destructive/40\" },\n}\ntype Status = keyof typeof statusMeta\n// Against where it should be today: within 10 points is on track, within 25 is at risk, further is behind.\nconst statusOf = (progress: number, expected: number): Status => (progress >= 0.999 ? \"done\" : progress >= expected - 0.1 ? \"track\" : progress >= expected - 0.25 ? \"risk\" : \"behind\")\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      <Skeleton className=\"h-36 rounded-3xl\" />\n      {[0, 1].map((i) => (\n        <Skeleton key={i} className=\"h-64 rounded-2xl\" />\n      ))}\n    </div>\n  )\n}\n\nfunction Bar({ value, expected, status }: { value: number; expected: number; status: Status }) {\n  const fill = { done: \"bg-chart-2\", track: \"bg-chart-2\", risk: \"bg-amber-500\", behind: \"bg-destructive\" }[status]\n  return (\n    <div className=\"bg-muted relative h-2.5 overflow-hidden rounded-full\">\n      <div className={cn(\"h-full rounded-full transition-[width] duration-700 ease-out motion-reduce:transition-none\", fill)} style={{ width: `${value * 100}%` }} />\n      <span aria-hidden=\"true\" className=\"bg-foreground/60 absolute inset-y-0 w-0.5\" style={{ insetInlineStart: `${expected * 100}%` }} />\n    </div>\n  )\n}\n\n/**\n * Objectives and key results for a quarter. Each key result shows where it started, where it is and where it should end, with a mark on its bar\n * for where it should be today, so on track, at risk and behind are judged against the calendar and written in words. Update a number in place and\n * the objective, the status and the quarter's overall progress all follow; a save that fails puts the old number back with the reason.\n */\nfunction Goals1({ objectives: objectivesProp = sample, title = \"Goals\", period = \"Q4 2026\", range = [\"2026-10-01\", \"2026-12-31\"], today = \"2026-11-12\", locale = \"en\", onUpdate, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Goals1Props) {\n  const id = React.useId()\n  const [objs, setObjs] = React.useState<Goals1Objective[]>(objectivesProp)\n  React.useEffect(() => setObjs(objectivesProp), [objectivesProp])\n  const list = state === \"empty\" ? [] : objs\n  const [team, setTeam] = React.useState<string | null>(null)\n  const [editing, setEditing] = React.useState<string | null>(null)\n  const [draft, setDraft] = React.useState(\"\")\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const n = (x: number) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(x)\n\n  const expected = clamp((toT(today) - toT(range[0])) / (toT(range[1]) - toT(range[0]) || DAY))\n  const daysLeft = Math.max(0, Math.round((toT(range[1]) - toT(today)) / DAY))\n  const teams = [...new Set(list.map((o) => o.team))]\n  const shown = list.filter((o) => !team || o.team === team)\n  const progressOf = (o: Goals1Objective) => (o.keyResults.length ? o.keyResults.reduce((s, k) => s + frac(k), 0) / o.keyResults.length : 0)\n  const overall = shown.length ? shown.reduce((s, o) => s + progressOf(o), 0) / shown.length : 0\n  const counts = ([\"done\", \"track\", \"risk\", \"behind\"] as Status[]).map((s) => [s, shown.filter((o) => statusOf(progressOf(o), expected) === s).length] as const)\n  const fmt = (k: Goals1KeyResult, v: number) => `${k.prefix ?? \"\"}${n(v)}${k.unit ?? \"\"}`\n\n  async function save(o: Goals1Objective, k: Goals1KeyResult) {\n    const v = Number(draft.replace(\",\", \".\"))\n    if (!Number.isFinite(v) || draft.trim() === \"\") return setError({ id: k.id, text: \"Enter a number.\" })\n    setError(null)\n    setBusy(k.id)\n    const prev = k.current\n    setObjs((x) => x.map((y) => (y.id === o.id ? { ...y, keyResults: y.keyResults.map((z) => (z.id === k.id ? { ...z, current: v } : z)) } : y)))\n    setEditing(null)\n    try {\n      await onUpdate?.(o, { ...k, current: v }, v)\n    } catch (e) {\n      setObjs((x) => x.map((y) => (y.id === o.id ? { ...y, keyResults: y.keyResults.map((z) => (z.id === k.id ? { ...z, current: prev } : z)) } : y)))\n      setError({ id: k.id, text: e instanceof Error ? e.message : `The update was not saved, so ${k.title} is back to ${fmt(k, prev)}.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Target className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No goals for this period yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Set an objective and the results that would show it worked. Progress is measured from there.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <>\n      <div className=\"relative isolate overflow-hidden rounded-3xl border\">\n        <CoverArt seed={period} variant=\"waves\" className=\"absolute inset-0 -z-20 opacity-50\" />\n        <div aria-hidden=\"true\" className=\"from-background via-background/90 to-background/50 absolute inset-0 -z-10 bg-gradient-to-r rtl:bg-gradient-to-l\" />\n        <div className=\"grid gap-6 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:p-8\">\n          <div className=\"flex items-center gap-5\">\n            <div className=\"relative\">\n              <svg aria-hidden=\"true\" viewBox=\"0 0 100 100\" className=\"size-28 -rotate-90 rtl:rotate-90\">\n                <circle cx=\"50\" cy=\"50\" r=\"42\" fill=\"none\" strokeWidth=\"9\" className=\"stroke-muted\" />\n                <circle cx=\"50\" cy=\"50\" r=\"42\" fill=\"none\" strokeWidth=\"9\" strokeLinecap=\"round\" strokeDasharray={2 * Math.PI * 42} strokeDashoffset={2 * Math.PI * 42 * (1 - overall)} className=\"stroke-foreground transition-[stroke-dashoffset] duration-700 motion-reduce:transition-none\" />\n              </svg>\n              <span className=\"absolute inset-0 flex items-center justify-center text-2xl font-semibold tabular-nums\">{Math.round(overall * 100)}%</span>\n            </div>\n            <div>\n              <p className=\"text-muted-foreground text-sm font-medium\">{period}</p>\n              <p className=\"text-lg font-semibold tracking-tight\">Overall progress</p>\n              <p className=\"text-muted-foreground text-sm tabular-nums\">\n                {Math.round(expected * 100)}% of the period gone · {daysLeft} {daysLeft === 1 ? \"day\" : \"days\"} left\n              </p>\n            </div>\n          </div>\n          <ul aria-label=\"Objectives by status\" className=\"flex flex-wrap gap-2 sm:justify-end\">\n            {counts.map(([s, c]) => (\n              <li key={s} className={cn(\"flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium\", statusMeta[s].chip)}>\n                {React.createElement(statusMeta[s].icon, { \"aria-hidden\": true, className: \"size-4\" })}\n                <span className=\"tabular-nums\">{c}</span> {statusMeta[s].label}\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n\n      {teams.length > 1 ? (\n        <div role=\"group\" aria-label=\"Filter by team\" className=\"mt-6 flex flex-wrap gap-2\">\n          {[null, ...teams].map((t) => (\n            <button key={t ?? \"all\"} type=\"button\" aria-pressed={team === t} onClick={() => setTeam(t)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", team === t ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n              {t ?? \"All teams\"}\n            </button>\n          ))}\n        </div>\n      ) : null}\n\n      <ul className=\"mt-6 grid gap-5\">\n        {shown.map((o) => {\n          const p = progressOf(o)\n          const st = statusOf(p, expected)\n          const M = statusMeta[st]\n          return (\n            <li key={o.id} className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n              <article aria-labelledby={`${id}-${o.id}`}>\n                <div className=\"flex flex-wrap items-start justify-between gap-3\">\n                  <div className=\"min-w-0\">\n                    <h2 id={`${id}-${o.id}`} className=\"text-lg font-semibold tracking-tight text-balance\">\n                      {o.title}\n                    </h2>\n                    <div className=\"text-muted-foreground mt-1 flex items-center gap-2 text-sm\">\n                      <span aria-hidden=\"true\" className=\"relative flex size-5 items-center justify-center overflow-hidden rounded-full border text-[9px] font-semibold\">\n                        <CoverArt seed={o.owner} variant=\"mesh\" className=\"absolute inset-0\" />\n                        <span className=\"relative\">{o.owner.split(\" \").map((w) => w[0]).join(\"\")}</span>\n                      </span>\n                      {o.owner} · {o.team}\n                    </div>\n                  </div>\n                  <p className={cn(\"flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium\", M.chip)}>\n                    <M.icon aria-hidden=\"true\" className=\"size-4\" />\n                    {M.label}\n                    <span className=\"tabular-nums\">· {Math.round(p * 100)}%</span>\n                  </p>\n                </div>\n                <div className=\"mt-4\">\n                  <Bar value={p} expected={expected} status={st} />\n                </div>\n\n                <ul className=\"mt-5 grid gap-4\">\n                  {o.keyResults.map((k) => {\n                    const kf = frac(k)\n                    const ks = statusOf(kf, expected)\n                    const isEdit = editing === k.id\n                    return (\n                      <li key={k.id}>\n                        <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n                          <p className=\"text-sm font-medium\">{k.title}</p>\n                          <p className=\"text-muted-foreground flex items-center gap-2 text-sm tabular-nums\">\n                            {isEdit ? (\n                              <span className=\"flex items-center gap-1.5\">\n                                <Input type=\"number\" inputMode=\"decimal\" step=\"any\" autoFocus aria-label={`New value for ${k.title}`} value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === \"Enter\") { e.preventDefault(); void save(o, k) } if (e.key === \"Escape\") setEditing(null) }} className=\"h-8 w-24 text-end\" />\n                                <Button size=\"icon\" variant=\"outline\" aria-label=\"Save value\" onClick={() => void save(o, k)}>\n                                  <Check aria-hidden=\"true\" />\n                                </Button>\n                                <Button size=\"icon\" variant=\"ghost\" aria-label=\"Cancel\" onClick={() => setEditing(null)}>\n                                  <X aria-hidden=\"true\" />\n                                </Button>\n                              </span>\n                            ) : (\n                              <>\n                                <span>\n                                  <span className=\"text-foreground font-semibold\">{fmt(k, k.current)}</span> of {fmt(k, k.target)}\n                                </span>\n                                <Button size=\"icon\" variant=\"ghost\" aria-label={`Update ${k.title}`} disabled={busy === k.id} onClick={() => { setEditing(k.id); setDraft(String(k.current)); setError(null) }}>\n                                  {busy === k.id ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : <Pencil aria-hidden=\"true\" />}\n                                </Button>\n                              </>\n                            )}\n                          </p>\n                        </div>\n                        <div className=\"mt-2\">\n                          <Bar value={kf} expected={expected} status={ks} />\n                        </div>\n                        <p className=\"text-muted-foreground mt-1.5 text-xs\">\n                          {statusMeta[ks].label}, {Math.round(kf * 100)}% of the way from {fmt(k, k.start)}\n                          {k.target < k.start ? \" (lower is better)\" : \"\"}\n                        </p>\n                        {error?.id === k.id ? (\n                          <p role=\"alert\" className=\"text-destructive mt-1.5 text-sm\">\n                            {error.text}\n                          </p>\n                        ) : null}\n                      </li>\n                    )\n                  })}\n                </ul>\n              </article>\n            </li>\n          )\n        })}\n      </ul>\n      <p className=\"text-muted-foreground mt-5 flex items-center gap-2 text-xs\">\n        <span aria-hidden=\"true\" className=\"bg-foreground/60 inline-block h-3 w-0.5\" />\n        The mark on each bar is where it should be today.\n      </p>\n    </>\n  )\n\n  return (\n    <section data-slot=\"goals-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      <h1 id={`${id}-title`} className=\"mb-6 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n        {title}\n      </h1>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading goals\" loading={<Skel />} error={{ title: errorState?.title ?? \"Goals could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Goals1, type Goals1Props, type Goals1Objective, type Goals1KeyResult }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/goals-1/goals-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "goals",
      "okr",
      "okrs",
      "objectives",
      "key results",
      "progress",
      "targets",
      "quarter",
      "planning",
      "team"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "OKRs. <Goals1 objectives={[{ id, title, owner, team, keyResults: [{ id, title, start, target, current, unit, prefix }] }]} period='Q4 2026' range={['2026-10-01', '2026-12-31']} today='2026-11-12' locale onUpdate={(objective, keyResult, value) => Promise} />. Key result progress is (current - start) / (target - start), clamped 0 to 1, so a target below the start means lower is better. Status compares progress with the share of the period gone: within 10 points On track, within 25 At risk, else Behind, 100% Done. Pass today yourself; the block never reads the clock. Reject onUpdate and the old value returns with the message.",
      "whenToUse": [
        "A team or company goals page",
        "A quarterly review dashboard"
      ],
      "whenNotToUse": [
        "A project plan with dates and dependencies (use gantt-1)",
        "A list of to-dos (use tasks-1)"
      ],
      "composesWith": [
        "gantt-1",
        "tasks-1",
        "funnel-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Escape",
          "action": "Each key result has an Update button named for it; Enter saves the number and Escape cancels"
        },
        {
          "keys": "Status",
          "action": "Written as On track, At risk, Behind or Done with an icon and the percentage, never by color alone; the pace mark is explained in a caption"
        },
        {
          "keys": "Summary",
          "action": "Counts by status are a labelled list ('2 On track, 1 At risk')"
        }
      ],
      "customization": [
        "unit goes after the number ('%', ' ms') and prefix before it ('$')",
        "Lower-is-better results are recognised from target < start and say so",
        "The bars, ring and pace marks mirror in right-to-left"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "goals-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton summary and objective cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No goals yet",
        "example": "goals-1-empty",
        "trigger": "state=\"empty\" (or objectives={[]})",
        "note": "Says to set an objective and the results that would show it worked; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "goals-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). An update that fails to save puts the old number back with the reason."
      }
    ],
    "examples": [
      "goals-1-demo",
      "goals-1-late",
      "goals-1-loading",
      "goals-1-empty",
      "goals-1-error"
    ],
    "url": "https://ui.ballmac.com/components/goals-1"
  },
  "categories": [
    "blocks",
    "goals"
  ],
  "type": "registry:block"
}