{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-countdown",
  "title": "useCountdown",
  "description": "A duration-based countdown state machine with start/pause/reset controls and a once-only onComplete callback.",
  "files": [
    {
      "path": "src/registry/hooks/use-countdown.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nexport interface UseCountdownOptions {\n  /** Start ticking immediately on mount. Defaults to false. */\n  autoStart?: boolean\n  /** Fires exactly once, the instant `remaining` reaches 0. `reset()` re-arms it. */\n  onComplete?: () => void\n}\n\nexport interface UseCountdownResult {\n  /** Seconds left. Ticks down while running, frozen while paused. */\n  remaining: number\n  running: boolean\n  /** (Re)start ticking from the current `remaining`. No-op if already at 0. */\n  start: () => void\n  /** Freeze `remaining` where it is — clears the timer, doesn't just ignore it. */\n  pause: () => void\n  /** Stop and set `remaining` back to the current `seconds` argument; re-arms `onComplete`. */\n  reset: () => void\n}\n\n/**\n * A duration countdown state machine: starts at `seconds` and steps down one whole second at a\n * time. This is the \"count a duration\" hook — OTP resend cooldowns, exam timers, limited-offer\n * buttons, anything shaped like \"give me N seconds\". It is **not** \"count down to a moment\"\n * (a date and time today); that is the presentational `Countdown` component's job (see\n * `src/registry/ui/countdown.tsx`). Neither replaces the other: the component derives\n * days/hours/minutes/seconds from a `target` for calendar-style displays, while this hook is a\n * plain numeric state machine and leaves the UI entirely to the consumer.\n *\n * - **`remaining` is derived from a deadline, not decremented once per tick.** That is\n *   deliberate: browsers throttle a background tab's `setInterval` down to once a minute, so a\n *   decrementing implementation **under-counts** — leave for a full 60 seconds, come back, and\n *   it still claims 45 to go. While running, a `deadline` is anchored and every wake-up\n *   recomputes `ceil((deadline - now) / 1000)`, so no matter how long it was throttled the\n *   number you return to is the real one; `visibilitychange` forces one extra recompute so you\n *   don't have to wait for the next tick. The cost is that `pause()` has to **settle the\n *   deadline back into a duration** before freezing (see `pause`) — the one seam between the\n *   duration and instant halves.\n * - Hitting 0 stops the run (`running` goes false) and calls `onComplete` exactly once (held\n *   in a latest-ref, so an inline arrow function from the consumer never rebuilds the timer);\n *   `reset()` re-arms it so the next run can fire it again.\n * - `pause()` settles the remainder into a whole second against the current clock, then clears\n *   the timer and the deadline; `remaining` freezes at that value — it neither zeroes nor\n *   keeps running.\n * - `reset()` stops the timer and pulls `remaining` back to the `seconds` of *this* render\n *   (not a snapshot from mount: if the caller swapped in a new `seconds` before pressing\n *   reset, the new value wins), and re-arms `onComplete`.\n * - `start`/`pause`/`reset` are `useCallback`-stable.\n * - Timing runs on a **self-renewing `setTimeout` aimed at the next whole-second boundary**\n *   rather than a fixed 1000ms `setInterval`: recomputing on every wake-up neither accumulates\n *   drift nor shows the same number twice because of a 1ms skew. Every `setState` happens\n *   inside a timer callback (legal, it is an async boundary); the effect body never sets state\n *   synchronously.\n * - Every change of `running` (pause, reset, the automatic stop at 0, start) clears the timer\n *   through the same effect cleanup-and-rebuild path, so two timers are never alive at once;\n *   unmount is covered by that same cleanup.\n */\nexport function useCountdown(\n  seconds: number,\n  options: UseCountdownOptions = {},\n): UseCountdownResult {\n  const { autoStart = false } = options\n\n  const [remaining, setRemaining] = React.useState(seconds)\n  const [running, setRunning] = React.useState(autoStart)\n\n  const onCompleteRef = React.useRef(options.onComplete)\n  React.useEffect(() => {\n    onCompleteRef.current = options.onComplete\n  })\n\n  const secondsRef = React.useRef(seconds)\n  React.useEffect(() => {\n    secondsRef.current = seconds\n  })\n\n  const remainingRef = React.useRef(remaining)\n  React.useEffect(() => {\n    remainingRef.current = remaining\n  })\n\n  // Fires once per completed run; reset() flips it back so the next run can\n  // fire onComplete again.\n  const firedRef = React.useRef(false)\n\n  // The deadline while running (epoch ms), null otherwise — the handover point between the\n  // duration and instant halves: running reads this, paused reads `remaining`.\n  const deadlineRef = React.useRef<number | null>(null)\n\n  React.useEffect(() => {\n    if (!running || remainingRef.current <= 0) return\n\n    // Re-anchored on every entry into the running state, so pause → start resumes from\n    // whatever was frozen.\n    deadlineRef.current = Date.now() + remainingRef.current * 1000\n    let timer: ReturnType<typeof setTimeout> | undefined\n\n    const settle = () => {\n      const deadline = deadlineRef.current\n      if (deadline === null) return\n      const left = deadline - Date.now()\n      // The crux: derive from the deadline instead of subtracting 1 from the last value.\n      // Waking up after 40 throttled seconds crosses all 40 at once, not one.\n      const next = Math.max(0, Math.ceil(left / 1000))\n\n      if (next <= 0) {\n        deadlineRef.current = null\n        remainingRef.current = 0\n        setRemaining(0)\n        setRunning(false)\n        if (!firedRef.current) {\n          firedRef.current = true\n          onCompleteRef.current?.()\n        }\n        return\n      }\n\n      if (next !== remainingRef.current) {\n        remainingRef.current = next\n        setRemaining(next)\n      }\n      // Aim at the next whole-second boundary rather than a flat +1000ms: no accumulated\n      // drift, and no showing the same number twice because of a few milliseconds of skew.\n      const toBoundary = left - (next - 1) * 1000\n      timer = setTimeout(settle, Math.max(16, toBoundary))\n    }\n\n    settle()\n\n    // Timers in a background tab are throttled to once a minute, so recompute the moment the\n    // tab comes back — nobody should stare at a stale number waiting for the next tick.\n    const onVisible = () => {\n      if (document.visibilityState === \"visible\") settle()\n    }\n    document.addEventListener(\"visibilitychange\", onVisible)\n\n    return () => {\n      if (timer !== undefined) clearTimeout(timer)\n      document.removeEventListener(\"visibilitychange\", onVisible)\n    }\n  }, [running])\n\n  const start = React.useCallback(() => {\n    // Makes good on the documented no-op at 0 — otherwise `running` sticks at true, because\n    // the timer effect bails out on remaining<=0 and nobody ever flips it back.\n    if (remainingRef.current <= 0) return\n    setRunning(true)\n  }, [])\n  const pause = React.useCallback(() => {\n    // Settle the deadline back into a duration before freezing, or the time spent paused\n    // vanishes on the next start(). The old decrementing version had no such seam; anchoring\n    // to an instant is what makes this step necessary.\n    const deadline = deadlineRef.current\n    if (deadline !== null) {\n      const next = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))\n      deadlineRef.current = null\n      remainingRef.current = next\n      setRemaining(next)\n    }\n    setRunning(false)\n  }, [])\n  const reset = React.useCallback(() => {\n    firedRef.current = false\n    deadlineRef.current = null\n    setRunning(false)\n    setRemaining(secondsRef.current)\n  }, [])\n\n  return { remaining, running, start, pause, reset }\n}\n\nexport default useCountdown\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}