{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"availability-badge","type":"registry:component","title":"Availability badge","description":"Shows when the owner is working, and when that overlaps with your day.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/availability-badge.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* The teachable part: the badge is one card clipped down to a pill. Opening\n   it animates clip-path from the pill's box to the card's box, so the pill\n   you are pointing at never moves or redraws; the rest of the card is\n   uncovered around it. The strip below it answers the question a visitor\n   actually has: when are we both awake and working? */\n\nexport type Availability = \"available\" | \"busy\" | \"away\";\n\nconst STATUS: Record<Availability, { label: string; dot: string }> = {\n  // Status colors are data, read as traffic lights, so they are raw values\n  // tuned per theme rather than tokens.\n  available: { label: \"Available for work\", dot: \"light-dark(#16a34a, #3fcf6e)\" },\n  busy: { label: \"Booked until March\", dot: \"light-dark(#d97706, #f5a524)\" },\n  away: { label: \"Away, back Monday\", dot: \"light-dark(#8f8f8f, #737373)\" },\n};\n\nconst PULSE_CSS = `\n@keyframes availability-sonar { from { transform: scale(1); opacity: .55 } to { transform: scale(2.6); opacity: 0 } }\n@keyframes availability-beat { 0%, 40%, 100% { transform: scale(1) } 12% { transform: scale(1.35) } 26% { transform: scale(1.15) } }\n@media (prefers-reduced-motion: reduce) { .availability-pulse { animation: none !important } }\n`;\n\nconst DAY = 1440;\n\n/* Time */\n\nfunction offsetMinutes(timeZone: string, at: Date) {\n  const parts = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    hourCycle: \"h23\",\n    year: \"numeric\",\n    month: \"numeric\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n  }).formatToParts(at);\n  const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);\n  const asUtc = Date.UTC(get(\"year\"), get(\"month\") - 1, get(\"day\"), get(\"hour\"), get(\"minute\"));\n  return Math.round((asUtc - Math.floor(at.getTime() / 60000) * 60000) / 60000);\n}\n\nconst minutesIn = (timeZone: string, at: Date) => {\n  const utc = at.getUTCHours() * 60 + at.getUTCMinutes();\n  return (((utc + offsetMinutes(timeZone, at)) % DAY) + DAY) % DAY;\n};\n\nfunction clock(minutes: number) {\n  const h = Math.floor(minutes / 60) % 24;\n  const m = minutes % 60;\n  const suffix = h < 12 ? \"am\" : \"pm\";\n  const h12 = h % 12 === 0 ? 12 : h % 12;\n  return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, \"0\")}${suffix}`;\n}\n\nconst city = (timeZone: string) => timeZone.split(\"/\").pop()!.replace(/_/g, \" \");\n\n// One shared minute clock. It ticks on the minute boundary, not every 60s\n// from mount, so the displayed time never lags the real one.\nconst minuteStore = (() => {\n  let now = 0;\n  const subs = new Set<() => void>();\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  const tick = () => {\n    now = Date.now();\n    subs.forEach((f) => f());\n    timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 50);\n  };\n  return {\n    subscribe(f: () => void) {\n      subs.add(f);\n      if (subs.size === 1) tick();\n      return () => {\n        subs.delete(f);\n        if (subs.size === 0) clearTimeout(timer);\n      };\n    },\n    get: () => now,\n  };\n})();\n\n// Zero on the server and during hydration, so nothing time based renders\n// until the client knows the real time.\nconst useNow = () => useSyncExternalStore(minuteStore.subscribe, minuteStore.get, () => 0);\n\nconst noop = () => () => {};\nconst useVisitorZone = () =>\n  useSyncExternalStore(\n    noop,\n    () => Intl.DateTimeFormat().resolvedOptions().timeZone,\n    () => null,\n  );\n\n/* Ranges on a 24h clock, split where they wrap past midnight. */\n\ntype Span = [number, number];\n\nfunction spans(start: number, end: number): Span[] {\n  const s = ((start % DAY) + DAY) % DAY;\n  const len = (((end - start) % DAY) + DAY) % DAY || DAY;\n  return s + len <= DAY ? [[s, s + len]] : [[s, DAY], [0, s + len - DAY]];\n}\n\nfunction intersect(a: Span[], b: Span[]): Span[] {\n  const out: Span[] = [];\n  for (const [a0, a1] of a)\n    for (const [b0, b1] of b) {\n      const s = Math.max(a0, b0);\n      const e = Math.min(a1, b1);\n      if (e > s) out.push([s, e]);\n    }\n  // A window crossing midnight comes back as two pieces; the one starting\n  // later in the day is where it really begins.\n  return out.sort((x, y) => x[0] - y[0]);\n}\n\n/* The little sky: a sun by day, a moon by night, riding one arc. */\n\nfunction Sky({ minutes }: { minutes: number }) {\n  const day = minutes >= 360 && minutes < 1080;\n  // 6am to 6pm maps across the arc for the sun, 6pm to 6am for the moon.\n  const t = ((minutes - (day ? 360 : 1080) + DAY) % DAY) / 720;\n  const angle = Math.PI * (1 - t);\n  const x = 12 + 9 * Math.cos(angle);\n  const y = 12 - 8 * Math.sin(angle);\n  return (\n    <svg viewBox=\"0 0 24 14\" className=\"h-4 w-7 shrink-0 overflow-visible\" aria-hidden>\n      <path d=\"M3 12a9 8 0 0 1 18 0\" fill=\"none\" stroke=\"currentColor\" strokeOpacity={0.45} strokeWidth={1} strokeDasharray=\"1.5 1.5\" />\n      <path d=\"M1.5 12h21\" stroke=\"currentColor\" strokeOpacity={0.45} strokeWidth={1} />\n      <g\n        style={{ transform: `translate(${x}px, ${y}px)` }}\n        className=\"transition-transform duration-700 ease-[cubic-bezier(0.23,1,0.32,1)]\"\n      >\n        {day ? (\n          <circle r={2.8} fill=\"currentColor\" />\n        ) : (\n          <path d=\"M1.2-2.4a2.6 2.6 0 1 0 1.3 3.6A2.1 2.1 0 0 1 1.2-2.4z\" fill=\"currentColor\" />\n        )}\n      </g>\n    </svg>\n  );\n}\n\nfunction StatusDot({ status }: { status: Availability }) {\n  const color = STATUS[status].dot;\n  return (\n    <span aria-hidden className=\"relative flex size-2.5 shrink-0 items-center justify-center\">\n      {status === \"available\" && (\n        // Sonar: open for business, broadcasting.\n        <span\n          className=\"availability-pulse absolute inset-0 rounded-full\"\n          style={{ background: color, animation: \"availability-sonar 2s cubic-bezier(0.23,1,0.32,1) infinite\" }}\n        />\n      )}\n      <span\n        className={cn(\"availability-pulse relative size-2.5 rounded-full\", status === \"away\" && \"border-2 bg-transparent\")}\n        style={\n          status === \"busy\"\n            ? // Heartbeat: alive and working, just not taking more.\n              { background: color, animation: \"availability-beat 1.6s ease-out infinite\" }\n            : status === \"away\"\n              ? // Hollow and still: nobody at the desk.\n                { borderColor: color }\n              : { background: color }\n        }\n      />\n    </span>\n  );\n}\n\nexport function AvailabilityBadge({\n  timeZone,\n  hours = [9, 18],\n  status = \"available\",\n  label,\n  replyHint = \"Replies within a day\",\n  name = \"me\",\n  visitorTimeZone,\n  visitorHours = [9, 18],\n  className,\n}: {\n  // IANA zone of the owner, like \"Europe/Lisbon\".\n  timeZone: string;\n  // Working hours in the owner's local time, 24h. Fractions allowed: 9.5 is 9:30.\n  hours?: [number, number];\n  status?: Availability;\n  label?: string;\n  replyHint?: string;\n  name?: string;\n  // Defaults to the visitor's own zone from Intl.\n  visitorTimeZone?: string;\n  // The visitor's assumed working day, for the overlap.\n  visitorHours?: [number, number];\n  className?: string;\n}) {\n  const id = useId();\n  const nowMs = useNow();\n  const detected = useVisitorZone();\n  const zone = visitorTimeZone ?? detected;\n  const [open, setOpen] = useState(false);\n  const card = useRef<HTMLDivElement>(null);\n  const pill = useRef<HTMLButtonElement>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  // How much of the card to cut away on the right and bottom to leave just\n  // the pill. Only changes on resize, never while opening.\n  const [clip, setClip] = useState({ right: 0, bottom: 0 });\n\n  useLayoutEffect(() => {\n    const c = card.current;\n    const p = pill.current;\n    if (!c || !p) return;\n    const measure = () =>\n      setClip({ right: c.offsetWidth - p.offsetWidth, bottom: c.offsetHeight - p.offsetHeight });\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(c);\n    ro.observe(p);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(() => () => clearTimeout(closeTimer.current), []);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open]);\n\n  const show = () => {\n    clearTimeout(closeTimer.current);\n    setOpen(true);\n  };\n  // A short grace period, so crossing the gap between pill and card edge,\n  // or a jittery pointer, never snaps it shut.\n  const hide = () => {\n    clearTimeout(closeTimer.current);\n    closeTimer.current = setTimeout(() => setOpen(false), 120);\n  };\n\n  const ready = nowMs > 0 && zone !== null;\n  const now = new Date(nowMs);\n  const ownerMinutes = ready ? minutesIn(timeZone, now) : 0;\n  const visitorMinutes = ready ? minutesIn(zone, now) : 0;\n  const shift = ready ? offsetMinutes(zone, now) - offsetMinutes(timeZone, now) : 0;\n  const same = shift === 0;\n\n  // Everything below is drawn on the visitor's clock.\n  const owner = spans(hours[0] * 60 + shift, hours[1] * 60 + shift);\n  const visitor = spans(visitorHours[0] * 60, visitorHours[1] * 60);\n  const both = intersect(owner, visitor);\n  // Rejoin a window split at midnight for the sentence.\n  const best: Span | null =\n    both.length === 0\n      ? null\n      : both.length === 2 && both[0][0] === 0 && both[1][1] === DAY\n        ? [both[1][0], both[0][1] + DAY]\n        : both.reduce((a, b) => (b[1] - b[0] > a[1] - a[0] ? b : a));\n\n  const sentence = !ready\n    ? \" \"\n    : best\n      ? `Best time to reach me: ${clock(best[0] % DAY)} to ${clock(best[1] % DAY)} your time`\n      : `Our days don't overlap. I start at ${clock(owner[0][0])} your time`;\n\n  const working = owner.some(([s, e]) => visitorMinutes >= s && visitorMinutes < e);\n  const pct = (m: number) => `${(m / DAY) * 100}%`;\n\n  return (\n    <div\n      className={cn(\"relative h-10 w-fit max-w-full\", className)}\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") show();\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType !== \"touch\") hide();\n      }}\n      // No open on focus: the keyboard opens it with Enter or Space, which\n      // would otherwise shut the card that focus had just opened.\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) hide();\n      }}\n    >\n      <style>{PULSE_CSS}</style>\n      {/* The drop shadow sits on a wrapper: a filter on the parent follows\n          the clipped shape, a box-shadow on the card would be clipped away. */}\n      {/* Two drop shadows: a hairline that stands in for a border (a real\n          border would be clipped at the pill's edge), and the lift. */}\n      <div className=\"absolute top-0 left-0 z-20 [filter:drop-shadow(0_0_0.5px_light-dark(oklch(0_0_0/0.28),oklch(1_0_0/0.22)))_drop-shadow(0_6px_14px_light-dark(oklch(0_0_0/0.08),oklch(0_0_0/0.5)))]\">\n        <div\n          ref={card}\n          style={{\n            clipPath: open\n              ? \"inset(0 0 0 0 round 20px)\"\n              : `inset(0 ${clip.right}px ${clip.bottom}px 0 round 20px)`,\n          }}\n          className={cn(\n            \"w-[400px] max-w-[calc(100vw-32px)] bg-background text-foreground dark:bg-muted\",\n            \"[transition-property:clip-path] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:duration-0\",\n            // Opening is the thing being looked at; closing just gets out of\n            // the way, so it is quicker.\n            open ? \"duration-[260ms]\" : \"duration-[180ms]\",\n          )}\n        >\n          <button\n            ref={pill}\n            type=\"button\"\n            aria-expanded={open}\n            aria-controls={`${id}-panel`}\n            // A mouse already opened it on hover, so its click must not shut\n            // it again; touch and keyboard toggle.\n            onClick={(e) => {\n              const n = e.nativeEvent;\n              const fromMouse = n instanceof PointerEvent && n.pointerType === \"mouse\";\n              setOpen((o) => (fromMouse ? true : !o));\n            }}\n            className=\"flex h-10 w-fit touch-manipulation items-center gap-2.5 rounded-full pr-4 pl-3.5 text-[14px] whitespace-nowrap outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            <StatusDot status={status} />\n            <span className=\"font-medium\">{label ?? STATUS[status].label}</span>\n            <span aria-hidden className=\"h-4 w-px bg-border\" />\n            <span className=\"flex items-center gap-1.5 text-muted-foreground tabular-nums\">\n              {ready && <Sky minutes={ownerMinutes} />}\n              <span className=\"min-w-[4.5ch]\">{ready ? clock(ownerMinutes) : \"\"}</span>\n              <span className=\"sr-only\">in {city(timeZone)}</span>\n            </span>\n          </button>\n\n          <div\n            id={`${id}-panel`}\n            inert={!open}\n            className={cn(\n              \"px-4 pt-2 pb-4 transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0\",\n              open\n                ? \"translate-y-0 opacity-100 blur-none delay-75 duration-200\"\n                : \"-translate-y-1 opacity-0 blur-[4px] duration-100\",\n            )}\n          >\n            <p className=\"text-[13px] text-muted-foreground\">\n              {replyHint}\n              {ready && (\n                <>\n                  {\" · \"}\n                  {working ? \"working now\" : \"off the clock right now\"} in {city(timeZone)}\n                </>\n              )}\n            </p>\n\n            <div className=\"mt-4 flex gap-3 text-[12px] text-muted-foreground\">\n              <div className=\"flex w-[52px] shrink-0 flex-col gap-2 pt-1.5\">\n                <span className=\"h-3 truncate leading-3\">{name === \"me\" ? \"Me\" : name}</span>\n                <span className=\"h-3 truncate leading-3\">You</span>\n              </div>\n              <div className=\"relative min-w-0 flex-1 pt-1.5\">\n                {/* The shared window, lit through both tracks. */}\n                {both.map(([s, e]) => (\n                  <span\n                    key={`w${s}`}\n                    aria-hidden\n                    className=\"absolute top-0 h-[42px] rounded-md bg-foreground/[0.07]\"\n                    style={{ left: pct(s), width: pct(e - s) }}\n                  />\n                ))}\n                <div className=\"flex flex-col gap-2\">\n                  <Track spans={owner} both={both} />\n                  <Track spans={visitor} both={both} />\n                </div>\n                <div className=\"relative mt-2 h-4\">\n                  {[0, 6, 12, 18].map((h) => (\n                    <span\n                      key={h}\n                      className=\"absolute top-0 -translate-x-1/2 tabular-nums first:translate-x-0\"\n                      style={{ left: pct(h * 60) }}\n                    >\n                      {clock(h * 60)}\n                    </span>\n                  ))}\n                </div>\n                {/* \"Now\" on the visitor's clock, through both tracks. */}\n                {ready && (\n                  <span\n                    aria-hidden\n                    className=\"pointer-events-none absolute top-0 h-[42px] w-0.5 -translate-x-1/2 rounded-full bg-foreground ring-2 ring-muted\"\n                    style={{ left: pct(visitorMinutes) }}\n                  />\n                )}\n              </div>\n            </div>\n\n            <p aria-live=\"polite\" className=\"mt-4 text-[14px] font-medium text-pretty\">\n              {sentence}\n            </p>\n            <p className=\"mt-0.5 text-[13px] text-muted-foreground\">\n              {ready && (same ? `We're both on ${city(timeZone)} time` : `You're in ${city(zone)}, I'm in ${city(timeZone)}`)}\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction Track({ spans, both }: { spans: Span[]; both: Span[] }) {\n  const pct = (m: number) => `${(m / DAY) * 100}%`;\n  return (\n    <div className=\"relative h-3 overflow-hidden rounded-full bg-foreground/[0.06]\">\n      {spans.map(([s, e]) => (\n        <span\n          key={s}\n          className=\"absolute inset-y-0 rounded-full bg-foreground/20\"\n          style={{ left: pct(s), width: pct(e - s) }}\n        />\n      ))}\n      {both.map(([s, e]) => (\n        <span\n          key={`o${s}`}\n          className=\"absolute inset-y-0 rounded-full bg-foreground\"\n          style={{ left: pct(s), width: pct(e - s) }}\n        />\n      ))}\n    </div>\n  );\n}\n\n/* A portfolio hero using it. */\n\nconst VIEWS = [\n  { value: undefined, label: \"Your zone\" },\n  { value: \"America/New_York\", label: \"New York\" },\n  { value: \"Asia/Kolkata\", label: \"Kolkata\" },\n  { value: \"Australia/Sydney\", label: \"Sydney\" },\n] as const;\n\nexport default function AvailabilityBadgeDemo() {\n  const [view, setView] = useState(0);\n  const [status, setStatus] = useState<Availability>(\"available\");\n  return (\n    <div className=\"flex w-[500px] max-w-full flex-col\">\n      {/* The top of a portfolio page: who, the badge, the pitch, the ask. */}\n      <div className=\"px-1\">\n        <div className=\"flex items-center gap-3\">\n          {/* eslint-disable-next-line @next/next/no-img-element -- a static\n              local SVG avatar; next/image adds nothing here. */}\n          <img\n            src=\"/avatars/cara.svg\"\n            alt=\"\"\n            width={44}\n            height={44}\n            className=\"size-11 rounded-full bg-[oklch(0.97_0_0)] outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10\"\n          />\n          <div className=\"min-w-0\">\n            <p className=\"text-[15px] font-medium text-foreground\">Inês Duarte</p>\n            <p className=\"text-[13px] text-muted-foreground\">Product designer, Lisbon</p>\n          </div>\n        </div>\n        <AvailabilityBadge\n          className=\"mt-7\"\n          timeZone=\"Europe/Lisbon\"\n          hours={[10, 19]}\n          status={status}\n          replyHint=\"Replies within 4 hours\"\n          visitorTimeZone={VIEWS[view].value}\n        />\n        <h2 className=\"mt-5 text-[30px]/[1.12] font-semibold tracking-[-0.02em] text-balance text-foreground\">\n          Calm software for busy teams.\n        </h2>\n        <p className=\"mt-3 max-w-[440px] text-[15px] leading-relaxed text-pretty text-muted-foreground\">\n          Previously design lead at a payments startup. Now taking on two projects a\n          quarter: design systems, onboarding, and anything with a dashboard.\n        </p>\n        <div className=\"mt-6 flex flex-wrap gap-2\">\n          <a\n            href=\"#book\"\n            onClick={(e) => e.preventDefault()}\n            className=\"flex h-10 items-center rounded-full bg-foreground px-4 text-sm font-medium text-background outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            Book an intro call\n          </a>\n          <a\n            href=\"#work\"\n            onClick={(e) => e.preventDefault()}\n            className=\"flex h-10 items-center rounded-full px-4 text-sm font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] outline-hidden transition-[scale,background-color] duration-150 ease-out hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            Selected work\n          </a>\n        </div>\n      </div>\n\n      <div className=\"mt-10 flex flex-col gap-3 border-t border-border pt-4\">\n        <Segmented\n          label=\"Status\"\n          options={([\"available\", \"busy\", \"away\"] as const).map((s) => ({ key: s, label: s[0].toUpperCase() + s.slice(1) }))}\n          value={status}\n          onChange={(k) => setStatus(k as Availability)}\n        />\n        <Segmented\n          label=\"View as\"\n          options={VIEWS.map((v, i) => ({ key: String(i), label: v.label }))}\n          value={String(view)}\n          onChange={(k) => setView(Number(k))}\n        />\n      </div>\n    </div>\n  );\n}\n\nfunction Segmented({\n  label,\n  options,\n  value,\n  onChange,\n}: {\n  label: string;\n  options: { key: string; label: string }[];\n  value: string;\n  onChange: (key: string) => void;\n}) {\n  return (\n    <div role=\"group\" aria-label={label} className=\"flex items-center gap-3\">\n      <span className=\"w-14 shrink-0 text-[13px] text-muted-foreground\">{label}</span>\n      <div className=\"flex min-w-0 flex-wrap gap-1\">\n        {options.map((o) => (\n          <button\n            key={o.key}\n            type=\"button\"\n            aria-pressed={value === o.key}\n            onClick={() => onChange(o.key)}\n            className={cn(\n              \"h-9 touch-manipulation rounded-full px-3 text-[13px] outline-hidden transition-[scale,background-color,color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n              value === o.key ? \"bg-foreground text-background\" : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n            )}\n          >\n            {o.label}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/availability-badge"}