{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "uptime-bars",
  "type": "registry:ui",
  "title": "Uptime Bars",
  "description": "Ninety days of status bars with a gliding per-day tooltip and incident detail.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/uptime-bars.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type UptimeStatus = \"up\" | \"degraded\" | \"down\" | \"none\";\n\nexport type UptimeIncident = { title: string; /** Minutes it lasted. */ minutes?: number; status?: \"degraded\" | \"down\" };\n\nexport type UptimeDay = {\n  /** ISO date, \"2026-09-21\". */\n  date: string;\n  /** Overrides the status derived from the minutes below. \"none\" means no data for the day. */\n  status?: UptimeStatus;\n  /** Minutes of full outage. Counts against uptime. */\n  downtime?: number;\n  /** Minutes of degraded service. Shown, but doesn't count against uptime. */\n  degraded?: number;\n  /** Overrides the day's uptime percentage (0–100). */\n  uptime?: number;\n  incidents?: UptimeIncident[];\n};\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(noop, () => Intl.DateTimeFormat().resolvedOptions().locale, () => \"en-US\");\n  return locale ?? detected;\n}\n\nexport function dayStatus(d: UptimeDay): UptimeStatus {\n  if (d.status) return d.status;\n  if ((d.downtime ?? 0) > 0) return \"down\";\n  if ((d.degraded ?? 0) > 0) return \"degraded\";\n  return \"up\";\n}\nexport const dayUptime = (d: UptimeDay) => d.uptime ?? Math.max(0, 100 - ((d.downtime ?? 0) / 1440) * 100);\n\nconst BAR: Record<UptimeStatus, string> = {\n  up: \"bg-success/75\",\n  degraded: \"bg-warning\",\n  down: \"bg-danger\",\n  none: \"bg-fg/[0.08]\",\n};\nconst DOT: Record<UptimeStatus, string> = { up: \"bg-success\", degraded: \"bg-warning\", down: \"bg-danger\", none: \"bg-fg-4\" };\nconst WORD: Record<UptimeStatus, string> = { up: \"Operational\", degraded: \"Degraded\", down: \"Outage\", none: \"No data\" };\nconst CURRENT: Record<UptimeStatus, string> = { up: \"Operational\", degraded: \"Degraded performance\", down: \"Outage\", none: \"No data\" };\n\nconst duration = (m: number) => (m >= 60 ? `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : \"\"}` : `${Math.max(1, Math.round(m))}m`);\n\nexport type UptimeBarsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** One entry per day, oldest first. The last entry is today. */\n  days: UptimeDay[];\n  /** The service or component being tracked. */\n  label: string;\n  /** Status shown in the header. Defaults to today's. */\n  current?: UptimeStatus;\n  /** Most days to show. Narrow containers step down to 60, then 30. */\n  maxDays?: number;\n  /** Narrowest a bar may draw, in pixels, before fewer days are shown. */\n  minBarWidth?: number;\n  /** Hides the name, status and axis, for a denser list. */\n  compact?: boolean;\n  loading?: boolean;\n  locale?: string;\n};\n\nexport function UptimeBars({\n  days,\n  label,\n  current,\n  maxDays = 90,\n  minBarWidth = 3,\n  compact = false,\n  loading = false,\n  locale: localeProp,\n  className,\n  ...rest\n}: UptimeBarsProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const uid = useId();\n  const wrap = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  const [active, setActive] = useState<number | null>(null);\n  const [pinned, setPinned] = useState(false);\n  const [kb, setKb] = useState(false);\n\n  useEffect(() => {\n    const el = wrap.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Fit the range to the width: 90 days, else 60, else 30, never drawing a bar thinner than minBarWidth.\n  const gap = 2;\n  const fits = width ? Math.floor((width + gap) / (minBarWidth + gap)) : maxDays;\n  const count = [maxDays, 60, 30].filter((n) => n <= maxDays).find((n) => n <= fits) ?? Math.max(1, fits);\n  const shown = useMemo(() => {\n    const tail = days.slice(-count);\n    // Pad the front with empty days, so a new service still reads as a full range.\n    const pad = Array.from({ length: Math.max(0, count - tail.length) }, (): UptimeDay => ({ date: \"\", status: \"none\" }));\n    return [...pad, ...tail];\n  }, [days, count]);\n\n  const measured = shown.filter((d) => d.date && dayStatus(d) !== \"none\");\n  const uptime = measured.length ? measured.reduce((s, d) => s + dayUptime(d), 0) / measured.length : null;\n  const today = days.length ? dayStatus(days[days.length - 1]) : \"none\";\n  const now = current ?? today;\n\n  const dateFmt = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const longFmt = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const pctFmt = useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: 0 }), [locale]);\n  const fmtDate = (iso: string, f: Intl.DateTimeFormat) => (iso ? f.format(Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10))) : \"\");\n  const pct = (v: number) => `${pctFmt.format(Math.floor(v * 100) / 100)}%`;\n\n  const describe = (d: UptimeDay) => {\n    if (!d.date) return \"No data\";\n    const s = dayStatus(d);\n    const parts = [fmtDate(d.date, longFmt), WORD[s]];\n    if (s !== \"none\") parts.push(`${pct(dayUptime(d))} uptime`);\n    for (const inc of d.incidents ?? []) parts.push(inc.minutes ? `${inc.title} for ${duration(inc.minutes)}` : inc.title);\n    return parts.join(\", \");\n  };\n\n  // One tooltip for the strip, glided between bars and kept inside it at both ends.\n  const tx = useMotionValue(0);\n  const shift = useTransform(tx, (x) => `${-Math.min(1, Math.max(0, x / (width || 1))) * 100}%`);\n  const visible = useRef(false);\n  const point = (i: number, instant: boolean) => {\n    const x = ((i + 0.5) * (width + gap)) / shown.length - gap / 2;\n    if (instant || !visible.current || reduce) tx.jump(x);\n    else animate(tx, x, spring.follow);\n    visible.current = true;\n    setActive(i);\n  };\n  const hide = () => {\n    visible.current = false;\n    setActive(null);\n    setPinned(false);\n    setKb(false);\n  };\n  const indexAt = (e: React.PointerEvent) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    return Math.min(shown.length - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * shown.length)));\n  };\n\n  // A tap pins the tooltip on touch screens; the next tap elsewhere lets it go.\n  useEffect(() => {\n    if (!pinned) return;\n    const away = (e: PointerEvent) => {\n      if (!wrap.current?.contains(e.target as Node)) {\n        visible.current = false;\n        setActive(null);\n        setPinned(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", away);\n    return () => document.removeEventListener(\"pointerdown\", away);\n  }, [pinned]);\n\n  const tipDay = active !== null ? shown[active] : null;\n  const tipStatus = tipDay ? (tipDay.date ? dayStatus(tipDay) : \"none\") : \"none\";\n  const cellId = (i: number) => `${uid}-d${i}`;\n\n  return (\n    <div\n      data-slot=\"uptime-bars\"\n      data-state={loading ? \"loading\" : now}\n      aria-busy={loading || undefined}\n      className={cn(\"flex w-full min-w-0 flex-col text-fg\", className)}\n      {...rest}\n    >\n      {!compact && (\n        <div className=\"mb-2.5 flex items-center justify-between gap-3\">\n          <span className=\"min-w-0 truncate text-[13px] font-medium tracking-[-0.01em]\">{label}</span>\n          <span className=\"flex shrink-0 items-center gap-1.5 text-[12px] text-fg-2\">\n            <span aria-hidden className=\"relative grid size-2 place-items-center\">\n              {!loading && (now === \"down\" || now === \"degraded\") && (\n                <span className={cn(\"absolute inset-0 animate-ping-soft rounded-full motion-reduce:hidden\", DOT[now])} />\n              )}\n              <span className={cn(\"size-1.5 rounded-full\", loading ? \"bg-fg-4\" : DOT[now])} />\n            </span>\n            {loading ? \"Checking…\" : CURRENT[now]}\n          </span>\n        </div>\n      )}\n\n      <div ref={wrap} className=\"relative\">\n        <div\n          role=\"grid\"\n          aria-label={`${label}, last ${count} days${uptime !== null ? `, ${pct(uptime)} uptime` : \"\"}`}\n          aria-readonly\n          aria-activedescendant={kb && active !== null ? cellId(active) : undefined}\n          tabIndex={loading ? -1 : 0}\n          data-hovering={active !== null || undefined}\n          onPointerMove={(e) => {\n            if (loading || e.pointerType !== \"mouse\" || pinned) return;\n            const i = indexAt(e);\n            if (i !== active) point(i, false);\n          }}\n          onPointerLeave={(e) => {\n            if (e.pointerType === \"mouse\" && !pinned && !kb) hide();\n          }}\n          onPointerDown={(e) => {\n            if (loading || e.pointerType === \"mouse\") return;\n            const i = indexAt(e);\n            if (pinned && i === active) return hide();\n            point(i, true);\n            setPinned(true);\n          }}\n          onFocus={(e) => {\n            if (!e.currentTarget.matches(\":focus-visible\") || loading) return;\n            setKb(true);\n            point(active ?? shown.length - 1, true);\n          }}\n          onBlur={hide}\n          onKeyDown={(e) => {\n            if (loading) return;\n            const last = shown.length - 1;\n            const from = active ?? last;\n            const to =\n              e.key === \"ArrowLeft\" ? Math.max(0, from - 1)\n              : e.key === \"ArrowRight\" ? Math.min(last, from + 1)\n              : e.key === \"Home\" ? 0\n              : e.key === \"End\" ? last\n              : e.key === \"PageUp\" ? Math.max(0, from - 7)\n              : e.key === \"PageDown\" ? Math.min(last, from + 7)\n              : null;\n            if (e.key === \"Escape\") return hide();\n            if (to === null) return;\n            e.preventDefault();\n            setKb(true);\n            // Arrowing is a keyboard move: the tooltip lands, it doesn't glide.\n            point(to, true);\n          }}\n          className={cn(\n            \"group/strip flex h-8 rounded-[3px] outline-none\",\n            \"focus-visible:outline-1 focus-visible:outline-offset-[3px] focus-visible:outline-fg-3 focus-visible:outline-solid\",\n          )}\n          style={{ gap }}\n        >\n          <div role=\"row\" className=\"contents\">\n            {shown.map((d, i) => {\n              const s = d.date ? dayStatus(d) : \"none\";\n              const on = active === i;\n              return (\n                <div\n                  key={d.date || `pad-${i}`}\n                  id={cellId(i)}\n                  role=\"gridcell\"\n                  aria-label={describe(d)}\n                  data-status={s}\n                  data-active={on || undefined}\n                  className={cn(\n                    \"min-w-0 flex-1 origin-bottom rounded-[2px] transition-[opacity,scale] duration-150 ease-out\",\n                    loading ? \"animate-pulse-soft bg-fg/[0.07]\" : BAR[s],\n                    // Pointing at one day quiets the rest, so its colour reads against them.\n                    \"group-data-hovering/strip:opacity-35 data-active:opacity-100 data-active:scale-y-[1.08] motion-reduce:data-active:scale-y-100\",\n                  )}\n                />\n              );\n            })}\n          </div>\n        </div>\n\n        <AnimatePresence>\n          {tipDay && !loading && (\n            <motion.div\n              key=\"tip\"\n              aria-hidden\n              className=\"pointer-events-none absolute left-0 top-0 z-(--z-tooltip)\"\n              style={{ x: tx }}\n              initial={{ opacity: 0, y: reduce ? 0 : 3 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.08 } }}\n              transition={{ duration: 0.14, ease: ease.out }}\n            >\n              <motion.div className=\"-translate-y-full pb-2.5\" style={{ x: shift }}>\n                <div className=\"w-max max-w-64 rounded-lg border border-line-2 bg-raised px-2.5 py-2 text-[12px] leading-4 shadow-pop\">\n                  <div className=\"flex items-baseline justify-between gap-4\">\n                    <span className=\"font-medium text-fg\">{tipDay.date ? fmtDate(tipDay.date, dateFmt) : \"Before tracking\"}</span>\n                    {tipStatus !== \"none\" && <span className=\"tabular font-mono text-[11px] text-fg-3\">{pct(dayUptime(tipDay))}</span>}\n                  </div>\n                  <div className=\"mt-1 flex items-center gap-1.5 text-fg-2\">\n                    <span aria-hidden className={cn(\"size-1.5 shrink-0 rounded-full\", DOT[tipStatus])} />\n                    {WORD[tipStatus]}\n                    {tipStatus === \"down\" && tipDay.downtime && !tipDay.incidents?.length ? <span className=\"text-fg-3\">· {duration(tipDay.downtime)} down</span> : null}\n                    {tipStatus === \"degraded\" && tipDay.degraded && !tipDay.incidents?.length ? <span className=\"text-fg-3\">· {duration(tipDay.degraded)}</span> : null}\n                  </div>\n                  {tipStatus === \"up\" && !tipDay.incidents?.length && <div className=\"mt-1 text-fg-3\">No incidents</div>}\n                  {tipDay.incidents?.map((inc, k) => (\n                    <div key={k} className=\"mt-1.5 border-t border-line pt-1.5 text-fg-2 first-of-type:mt-2\">\n                      {inc.title}\n                      {inc.minutes ? <span className=\"tabular text-fg-3\"> · {duration(inc.minutes)}</span> : null}\n                    </div>\n                  ))}\n                </div>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {!compact && (\n        <div className=\"mt-2 flex items-center gap-3 text-2xs text-fg-4\">\n          <span className=\"tabular shrink-0\">{count} days ago</span>\n          <span aria-hidden className=\"h-px flex-1 bg-line\" />\n          <span className=\"tabular shrink-0 text-fg-3\">\n            {loading ? (\n              \"Loading…\"\n            ) : uptime === null ? (\n              \"No data yet\"\n            ) : (\n              <>\n                <NumberFlow value={Math.floor(uptime * 100) / 10000} locales={locale} format={{ style: \"percent\", maximumFractionDigits: 2 }} animated={!reduce} willChange />{\" \"}\n                uptime\n              </>\n            )}\n          </span>\n          <span aria-hidden className=\"h-px flex-1 bg-line\" />\n          <span className=\"shrink-0\">Today</span>\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/uptime-bars"
}
