{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "habits-1",
  "title": "Habits 1: streaks and weekly goals",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A habit tracker: each habit with its streak (kept alive until today ends), best streak, this week against its goal, the last seven days to tick, twelve weeks as a grid, a ring for today, and ticks that go back if they fail.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.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/habits-1/habits-1.tsx",
      "content": "// Ballmac UI: Habits 1. https://ui.ballmac.com/blocks/habits-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, CircleAlert, Flame, Plus, Sprout, Trophy } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\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 Habits1Habit = {\n  id: string\n  name: string\n  /** How many days a week is the goal. */\n  target: number\n  /** ISO dates it was done. */\n  done: string[]\n  /** One of the five chart colors, 1 to 5. */\n  tone?: 1 | 2 | 3 | 4 | 5\n}\n\ntype Habits1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    habits?: Habits1Habit[]\n    /** An ISO date for \"today\". The block never reads the clock. */\n    today?: string\n    title?: string\n    locale?: string\n    /** Called when a day is ticked or unticked. Return a promise; reject and the day goes back with a message. */\n    onToggle?: (habitId: string, date: string, done: boolean) => void | Promise<void>\n    onAdd?: (habit: { name: string; target: number }) => void | Promise<void>\n  }\n\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10)\n\n// A believable history without any randomness, so the server and the browser draw the same grid.\nconst hist = (today: string, every: number, offset: number, upTo: number): string[] => Array.from({ length: 84 }, (_, i) => i).filter((i) => i >= upTo && (i * 7 + offset) % every !== 0 && (i * 3 + offset) % 5 !== 0).map((i) => iso(toT(today) - i * DAY))\nconst sample = (today: string): Habits1Habit[] => [\n  { id: \"h1\", name: \"Read for 20 minutes\", target: 5, done: hist(today, 9, 2, 0), tone: 1 },\n  { id: \"h2\", name: \"Walk 8,000 steps\", target: 6, done: hist(today, 7, 1, 1), tone: 2 },\n  { id: \"h3\", name: \"Write three lines\", target: 7, done: hist(today, 4, 3, 2), tone: 3 },\n  { id: \"h4\", name: \"No screens after 22:00\", target: 5, done: hist(today, 6, 0, 0).filter((_, k) => k % 2 === 0 || k > 30), tone: 4 },\n]\n\nconst tones = [\"\", \"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4\">\n      <Skeleton className=\"h-28 rounded-2xl\" />\n      {[0, 1, 2].map((i) => (\n        <Skeleton key={i} className=\"h-44 rounded-2xl\" />\n      ))}\n    </div>\n  )\n}\n\n// A streak is the run of days ending today, or ending yesterday if today is not done yet, so it is not lost until the day is over.\nfunction streakOf(done: Set<string>, today: string) {\n  let t = toT(today)\n  if (!done.has(iso(t))) t -= DAY\n  let n = 0\n  while (done.has(iso(t))) { n++; t -= DAY }\n  return n\n}\nfunction bestOf(done: Set<string>) {\n  const days = [...done].map(toT).sort((a, b) => a - b)\n  let best = 0\n  let run = 0\n  days.forEach((d, i) => { run = i && d - days[i - 1]! === DAY ? run + 1 : 1; best = Math.max(best, run) })\n  return best\n}\n\n/**\n * A habit tracker: each habit shows its streak (kept alive until today is over), its best streak, this week against its goal, the last seven days to tick or untick,\n * and twelve weeks as a grid. The ring at the top counts what is done today. A tick is counted at once and goes back, with the reason, if it cannot be saved.\n */\nfunction Habits1({ habits: habitsProp, today = \"2026-10-18\", title = \"Habits\", locale = \"en\", onToggle, onAdd, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Habits1Props) {\n  const id = React.useId()\n  const [habits, setHabits] = React.useState<Habits1Habit[]>(() => habitsProp ?? sample(today))\n  React.useEffect(() => { if (habitsProp) setHabits(habitsProp) }, [habitsProp])\n  const list = state === \"empty\" ? [] : habits\n  const [error, setError] = React.useState<{ id: string; text: string } | null>(null)\n  const [name, setName] = React.useState(\"\")\n  const [target, setTarget] = React.useState(5)\n  const [announce, setAnnounce] = React.useState(\"\")\n  const dayFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"narrow\", timeZone: \"UTC\" }), [locale])\n  const longFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"long\", day: \"numeric\", month: \"long\", timeZone: \"UTC\" }), [locale])\n  const last7 = Array.from({ length: 7 }, (_, i) => iso(toT(today) - (6 - i) * DAY))\n  // Weeks run Monday to Sunday; \"this week\" is the Monday on or before today up to today.\n  const dow = (new Date(toT(today)).getUTCDay() + 6) % 7\n  const weekDays = Array.from({ length: dow + 1 }, (_, i) => iso(toT(today) - i * DAY))\n  const doneToday = list.filter((h) => h.done.includes(today)).length\n\n  async function toggle(h: Habits1Habit, date: string) {\n    const on = !h.done.includes(date)\n    const before = habits\n    setError(null)\n    setHabits((hs) => hs.map((x) => (x.id === h.id ? { ...x, done: on ? [...x.done, date] : x.done.filter((d) => d !== date) } : x)))\n    setAnnounce(`${h.name}, ${longFmt.format(toT(date))}: ${on ? \"done\" : \"not done\"}`)\n    try {\n      await onToggle?.(h.id, date, on)\n    } catch (e) {\n      setHabits(before)\n      setError({ id: h.id, text: e instanceof Error ? e.message : `That change was not saved, so ${longFmt.format(toT(date))} went back.` })\n    }\n  }\n  async function add(e: React.FormEvent) {\n    e.preventDefault()\n    const n = name.trim()\n    if (!n) return\n    const h: Habits1Habit = { id: `n${habits.length}${n.length}`, name: n, target, done: [], tone: ((habits.length % 5) + 1) as 1 | 2 | 3 | 4 | 5 }\n    setHabits((hs) => [...hs, h])\n    setName(\"\")\n    try { await onAdd?.({ name: n, target }) } catch { setHabits((hs) => hs.filter((x) => x.id !== h.id)); setError({ id: \"new\", text: \"The habit could not be added, so it was taken out again.\" }) }\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        <Sprout className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No habits yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty\">{emptyState?.description ?? \"Pick one small thing to do most days. A streak starts with the first tick.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid gap-5\">\n      <div className=\"bg-card flex flex-wrap items-center gap-6 rounded-3xl border p-5 sm:p-7\">\n        <div className=\"relative\">\n          <svg aria-hidden=\"true\" viewBox=\"0 0 100 100\" className=\"size-24 -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 - doneToday / list.length)} className=\"stroke-foreground transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none\" />\n          </svg>\n          <span className=\"absolute inset-0 flex items-center justify-center text-xl font-semibold tabular-nums\">\n            {doneToday}/{list.length}\n          </span>\n        </div>\n        <div>\n          <p className=\"text-muted-foreground text-sm\">{longFmt.format(toT(today))}</p>\n          <p className=\"text-xl font-semibold tracking-tight text-balance\">{doneToday === list.length ? \"Every habit done today\" : doneToday ? `${list.length - doneToday} to go today` : \"Nothing ticked yet today\"}</p>\n        </div>\n      </div>\n\n      <ul className=\"grid gap-4\">\n        {list.map((h) => {\n          const set = new Set(h.done)\n          const streak = streakOf(set, today)\n          const best = Math.max(bestOf(set), streak)\n          const week = weekDays.filter((d) => set.has(d)).length\n          const hit = week >= h.target\n          const dot = tones[h.tone ?? 1]\n          return (\n            <li key={h.id} className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n              <article aria-labelledby={`${id}-${h.id}`}>\n                <div className=\"flex flex-wrap items-start justify-between gap-3\">\n                  <div className=\"min-w-0\">\n                    <h2 id={`${id}-${h.id}`} className=\"flex items-center gap-2.5 text-lg font-semibold tracking-tight\">\n                      <span aria-hidden=\"true\" className={cn(\"size-3 shrink-0 rounded-full\", dot)} />\n                      {h.name}\n                    </h2>\n                    <p className=\"text-muted-foreground mt-1 text-sm tabular-nums\">\n                      {week} of {h.target} this week{hit ? \", goal reached\" : \"\"}\n                    </p>\n                  </div>\n                  <div className=\"flex items-center gap-4 text-sm tabular-nums\">\n                    <p className=\"flex items-center gap-1.5 font-semibold\">\n                      <Flame aria-hidden=\"true\" className={cn(\"size-5\", streak ? \"text-orange-500\" : \"text-muted-foreground\")} />\n                      {streak} {streak === 1 ? \"day\" : \"days\"}\n                      <span className=\"sr-only\"> streak</span>\n                    </p>\n                    <p className=\"text-muted-foreground flex items-center gap-1.5\">\n                      <Trophy aria-hidden=\"true\" className=\"size-4\" />\n                      Best {best}\n                    </p>\n                  </div>\n                </div>\n\n                <ol aria-label={`Last seven days of ${h.name}`} className=\"mt-5 grid grid-cols-7 gap-2\">\n                  {last7.map((d) => {\n                    const on = set.has(d)\n                    return (\n                      <li key={d} className=\"text-center\">\n                        <span aria-hidden=\"true\" className=\"text-muted-foreground mb-1.5 block text-xs\">\n                          {dayFmt.format(toT(d))}\n                        </span>\n                        <button type=\"button\" role=\"checkbox\" aria-checked={on} aria-label={`${h.name}, ${longFmt.format(toT(d))}${d === today ? \", today\" : \"\"}`} onClick={() => void toggle(h, d)} className={cn(\"focus-visible:ring-ring/60 mx-auto flex size-10 items-center justify-center rounded-full border-2 outline-none transition-[transform,background-color] focus-visible:ring-[3px] active:scale-90 motion-reduce:transition-none sm:size-11\", on ? cn(\"border-transparent text-white\", dot) : \"hover:bg-accent\", d === today && !on && \"border-foreground\")}>\n                          {on ? <Check aria-hidden=\"true\" className=\"size-5\" /> : null}\n                        </button>\n                      </li>\n                    )\n                  })}\n                </ol>\n\n                <div dir=\"ltr\" role=\"img\" aria-label={`Twelve weeks: done on ${h.done.filter((d) => toT(today) - toT(d) < 84 * DAY && toT(d) <= toT(today)).length} of the last 84 days`} className=\"mt-5 grid w-fit grid-flow-col grid-rows-[repeat(7,0.875rem)] auto-cols-[0.875rem] gap-[3px]\">\n                  {Array.from({ length: 84 }, (_, i) => {\n                    const col = Math.floor(i / 7)\n                    const row = i % 7\n                    // Oldest on the left; the last column is the current week with its days from Monday.\n                    const dayIndex = (11 - col) * 7 + (dow - row)\n                    const d = iso(toT(today) - dayIndex * DAY)\n                    const future = dayIndex < 0\n                    return <span key={i} className={cn(\"size-3.5 rounded-[3px]\", future ? \"bg-transparent\" : set.has(d) ? dot : \"bg-muted\")} />\n                  })}\n                </div>\n                {error?.id === h.id ? (\n                  <p role=\"alert\" className=\"text-destructive mt-3 flex items-start gap-2 text-sm\">\n                    <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n                    {error.text}\n                  </p>\n                ) : null}\n              </article>\n            </li>\n          )\n        })}\n      </ul>\n\n      <form onSubmit={add} className=\"bg-muted/30 flex flex-wrap items-end gap-3 rounded-2xl border border-dashed p-4 sm:p-5\">\n        <div className=\"min-w-48 flex-1\">\n          <label htmlFor={`${id}-n`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">New habit</label>\n          <Input id={`${id}-n`} value={name} onChange={(e) => setName(e.target.value)} placeholder=\"Stretch for ten minutes\" />\n        </div>\n        <div>\n          <label htmlFor={`${id}-t`} className=\"text-muted-foreground mb-1.5 block text-xs font-medium\">Days a week</label>\n          <select id={`${id}-t`} value={target} onChange={(e) => setTarget(Number(e.target.value))} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]\">\n            {[1, 2, 3, 4, 5, 6, 7].map((n) => (\n              <option key={n} value={n}>\n                {n}\n              </option>\n            ))}\n          </select>\n        </div>\n        <Button type=\"submit\" variant=\"outline\" disabled={!name.trim()}>\n          <Plus aria-hidden=\"true\" />\n          Add habit\n        </Button>\n        {error?.id === \"new\" ? (\n          <p role=\"alert\" className=\"text-destructive w-full text-sm\">\n            {error.text}\n          </p>\n        ) : null}\n      </form>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"habits-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-3xl 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 your habits\" loading={<Skel />} error={{ title: errorState?.title ?? \"Your habits could not be loaded\", description: errorState?.description ?? \"Nothing was lost. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </p>\n    </section>\n  )\n}\n\nexport { Habits1, type Habits1Props, type Habits1Habit }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/habits-1/habits-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "habits",
      "streak",
      "habit tracker",
      "routine",
      "goals",
      "daily",
      "wellness",
      "consistency",
      "check-in"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A habit tracker. <Habits1 habits={[{ id, name, target: 5, done: ['2026-10-18'], tone: 1..5 }]} today='2026-10-18' locale onToggle={(habitId, date, done) => Promise} onAdd={({ name, target }) => Promise} />. The streak is the run of days ending today, or yesterday while today is not done yet, so it only breaks when a day is missed. Best streak, this-week count (Monday to today) and the goal are worked out from done. Pass today yourself; the block never reads the clock. Reject onToggle and the day goes back with the message.",
      "whenToUse": [
        "Wellness, learning and productivity apps with daily routines",
        "A team ritual tracker"
      ],
      "whenNotToUse": [
        "One-off to-dos (use tasks-1)",
        "Long-term targets with numbers (use goals-1)"
      ],
      "composesWith": [
        "goals-1",
        "tasks-1",
        "leaderboard-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Space",
          "action": "Each day is a checkbox named for the habit and the full date ('Read for 20 minutes, Sunday 18 October, today'); toggling is announced politely"
        },
        {
          "keys": "Streak",
          "action": "'12 days streak' and 'Best 18' are written in text with icons; this week reads '4 of 5 this week, goal reached'"
        },
        {
          "keys": "Twelve weeks",
          "action": "The grid is a picture with a text equivalent giving how many of the last 84 days were done"
        }
      ],
      "customization": [
        "tone picks one of five chart colors per habit",
        "The history grid runs oldest to newest left to right in every language, like a chart",
        "target is days a week; a week counts Monday to Sunday"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "habits-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No habits yet",
        "example": "habits-1-empty",
        "trigger": "state=\"empty\" (or habits={[]})",
        "note": "Says to pick one small thing to do most days; emptyState sets the copy and can add a button."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "habits-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was lost, with Try again (onRetry). A tick that fails to save goes back with its reason under the habit."
      }
    ],
    "examples": [
      "habits-1-demo",
      "habits-1-fresh",
      "habits-1-loading",
      "habits-1-empty",
      "habits-1-error"
    ],
    "url": "https://ui.ballmac.com/components/habits-1"
  },
  "categories": [
    "blocks",
    "habits"
  ],
  "type": "registry:block"
}