{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "uptime-bar",
  "type": "registry:ui",
  "title": "Uptime Bar",
  "description": "Status-page uptime bars: one bar per day, computed overall uptime, a legend and tooltips on hover, tap and keyboard focus.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/uptime-bar.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type UptimeStatus = \"up\" | \"degraded\" | \"down\" | \"none\";\n\nexport interface UptimeDay {\n  /** \"yyyy-mm-dd\". */\n  date: string;\n  status: UptimeStatus;\n  /** Uptime for the day in percent (0–100). */\n  uptime?: number;\n  /** Incident or maintenance note shown in the tooltip. */\n  note?: string;\n}\n\nexport interface UptimeBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Days in chronological order; the most recent ones are shown. */\n  days: UptimeDay[];\n  /** Service name; labels the grid and the header. */\n  label?: string;\n  /** Maximum number of bars (days). Fewer are shown when the container is too narrow. */\n  maxBars?: number;\n  /** Narrowest a bar may get before older days are dropped, in px. */\n  minBarWidth?: number;\n  /** Bar height in px. */\n  barHeight?: number;\n  /** Gap between bars in px. */\n  gap?: number;\n  /** Bar corner radius in px. */\n  radius?: number;\n  /** Header with the current status and overall uptime. */\n  showSummary?: boolean;\n  /** Legend with a key for each status. */\n  showLegend?: boolean;\n  upColor?: string;\n  degradedColor?: string;\n  downColor?: string;\n  /** Locale for dates and percentages. */\n  locale?: string;\n}\n\nconst STATUS_TEXT: Record<UptimeStatus, string> = {\n  up: \"Operational\",\n  degraded: \"Degraded performance\",\n  down: \"Outage\",\n  none: \"No data\",\n};\n\n/** Estimated uptime when a day has no explicit `uptime`. */\nconst ASSUMED_UPTIME: Record<Exclude<UptimeStatus, \"none\">, number> = { up: 100, degraded: 100, down: 0 };\n\n/** Fill per status. Degraded days are hatched and empty days outlined, so colour is never the only cue. */\nfunction barStyle(status: UptimeStatus): React.CSSProperties {\n  switch (status) {\n    case \"up\":\n      return { background: \"var(--ub-up)\" };\n    case \"degraded\":\n      return {\n        background: \"repeating-linear-gradient(135deg, var(--ub-degraded) 0 3px, color-mix(in oklab, var(--ub-degraded) 55%, transparent) 3px 5px)\",\n      };\n    case \"down\":\n      return { background: \"var(--ub-down)\" };\n    default:\n      return {};\n  }\n}\n\ninterface Tip {\n  index: number;\n  x: number;\n  y: number;\n  arrow: number;\n}\n\n/**\n * Status-page style uptime bars: one thin bar per day, a computed overall uptime, a legend and a\n * tooltip on hover, tap or keyboard focus. The bars form a one-row ARIA grid with roving focus,\n * and the number of bars adapts to the container width.\n */\nexport function UptimeBar({\n  days,\n  label = \"Uptime\",\n  maxBars = 90,\n  minBarWidth = 3,\n  barHeight = 32,\n  gap = 2,\n  radius = 1,\n  showSummary = true,\n  showLegend = true,\n  upColor = \"#2FBF71\",\n  degradedColor = \"#F5B83D\",\n  downColor = \"#E5484D\",\n  locale = \"en-GB\",\n  className = \"\",\n  style,\n  ...props\n}: UptimeBarProps) {\n  const id = React.useId();\n  const summaryId = `${id}-summary`;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const barsRef = React.useRef<HTMLDivElement>(null);\n  const cellRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  // How many bars fit; unknown on the server, so all `maxBars` render until measured.\n  const [fit, setFit] = React.useState(Infinity);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [tip, setTip] = React.useState<Tip | null>(null);\n\n  React.useEffect(() => {\n    const el = barsRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => {\n      setFit(Math.max(1, Math.floor((entry.contentRect.width + gap) / (Math.max(minBarWidth, 1) + gap))));\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [gap, minBarWidth]);\n\n  const formats = React.useMemo(() => {\n    const safe = <T,>(make: (l: string) => T, fallback: () => T) => {\n      try {\n        return make(locale);\n      } catch {\n        return fallback();\n      }\n    };\n    return {\n      date: safe(\n        (l) => new Intl.DateTimeFormat(l, { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }),\n        () => new Intl.DateTimeFormat(\"en-GB\", { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }),\n      ),\n      percent: safe(\n        (l) => new Intl.NumberFormat(l, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),\n        () => new Intl.NumberFormat(\"en-GB\", { maximumFractionDigits: 2, minimumFractionDigits: 2 }),\n      ),\n    };\n  }, [locale]);\n\n  const count = Math.max(0, Math.min(maxBars, days.length, fit));\n  const visible = days.slice(days.length - count);\n  const formatDate = (iso: string) => {\n    const t = Date.parse(`${iso}T00:00:00Z`);\n    return Number.isNaN(t) ? iso : formats.date.format(new Date(t));\n  };\n\n  // Overall uptime across days with data.\n  const measured = visible.filter((d) => d.status !== \"none\");\n  const overall = measured.length\n    ? measured.reduce((sum, d) => sum + (d.uptime ?? ASSUMED_UPTIME[d.status as Exclude<UptimeStatus, \"none\">]), 0) / measured.length\n    : null;\n  const incidents = visible.filter((d) => d.status === \"down\" || d.status === \"degraded\").length;\n  const latest = visible[visible.length - 1];\n  const overallText = overall === null ? \"—\" : `${formats.percent.format(overall)}%`;\n\n  const describe = (d: UptimeDay) =>\n    `${formatDate(d.date)}: ${STATUS_TEXT[d.status]}${d.uptime !== undefined ? `, ${formats.percent.format(d.uptime)}% uptime` : \"\"}${d.note ? `. ${d.note}` : \"\"}`;\n\n  const tabStop = active !== null && active < count ? active : count - 1;\n\n  const showTip = (index: number, target: HTMLElement) => {\n    const root = rootRef.current;\n    if (!root) return;\n    const r = root.getBoundingClientRect();\n    const b = target.getBoundingClientRect();\n    const centre = b.left + b.width / 2 - r.left;\n    const half = 110; // about half the tooltip width; keeps it inside the component\n    const x = r.width > half * 2 ? Math.min(Math.max(centre, half), r.width - half) : r.width / 2;\n    setTip({ index, x, y: b.top - r.top, arrow: centre - x });\n  };\n\n  const focusBar = (index: number) => {\n    const next = Math.max(0, Math.min(count - 1, index));\n    setActive(next);\n    cellRefs.current[next]?.focus();\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const moves: Record<string, () => void> = {\n      ArrowLeft: () => focusBar(tabStop - 1),\n      ArrowRight: () => focusBar(tabStop + 1),\n      PageUp: () => focusBar(tabStop - 7),\n      PageDown: () => focusBar(tabStop + 7),\n      Home: () => focusBar(0),\n      End: () => focusBar(count - 1),\n      Escape: () => setTip(null),\n    };\n    const action = moves[event.key];\n    if (!action) return;\n    event.preventDefault();\n    action();\n  };\n\n  const tipDay = tip ? visible[tip.index] : undefined;\n  const dot = (status: UptimeStatus, extra = \"\") => (\n    <span\n      aria-hidden=\"true\"\n      className={`inline-block shrink-0 ${status === \"none\" ? \"border border-dashed border-zinc-900/30 dark:border-white/30\" : \"\"} ${extra}`}\n      style={{ ...barStyle(status), borderRadius: Math.min(radius, 2) }}\n    />\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative w-full text-zinc-900 dark:text-zinc-100 ${className}`}\n      style={{ [\"--ub-up\" as string]: upColor, [\"--ub-degraded\" as string]: degradedColor, [\"--ub-down\" as string]: downColor, ...style }}\n      {...props}\n    >\n      <p id={summaryId} className=\"sr-only\">\n        {`${label}: ${overallText} uptime over the last ${count} ${count === 1 ? \"day\" : \"days\"}, ${incidents} ${incidents === 1 ? \"day\" : \"days\"} with incidents. Use the arrow keys to move between days.`}\n      </p>\n\n      {showSummary && (\n        <div className=\"mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            {latest && dot(latest.status, \"size-2 rounded-full\")}\n            <span className=\"truncate text-sm font-medium tracking-tight\">{label}</span>\n            {latest && (\n              <span className=\"font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">{STATUS_TEXT[latest.status]}</span>\n            )}\n          </div>\n          <span className=\"font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-300\">\n            <span className=\"text-zinc-900 dark:text-zinc-100\">{overallText}</span> uptime\n          </span>\n        </div>\n      )}\n\n      <div\n        ref={barsRef}\n        role=\"grid\"\n        aria-label={`${label}, daily status`}\n        aria-describedby={summaryId}\n        aria-readonly=\"true\"\n        className=\"w-full\"\n        onKeyDown={handleKeyDown}\n        onPointerLeave={() => setTip(null)}\n      >\n        <div role=\"row\" className=\"flex w-full\" style={{ gap, height: barHeight }}>\n          {visible.map((d, i) => (\n            <div\n              key={`${d.date}-${i}`}\n              ref={(el) => {\n                cellRefs.current[i] = el;\n              }}\n              role=\"gridcell\"\n              tabIndex={i === tabStop ? 0 : -1}\n              aria-label={describe(d)}\n              onFocus={(event) => {\n                setActive(i);\n                showTip(i, event.currentTarget);\n              }}\n              onBlur={() => setTip(null)}\n              onPointerEnter={(event) => {\n                if (event.pointerType === \"mouse\") showTip(i, event.currentTarget);\n              }}\n              onPointerDown={(event) => showTip(i, event.currentTarget)}\n              className={`min-w-0 flex-1 outline-offset-2 transition-opacity duration-150 focus-visible:outline-2 focus-visible:outline-zinc-900 motion-reduce:transition-none dark:focus-visible:outline-white ${\n                d.status === \"none\" ? \"border border-dashed border-zinc-900/20 bg-zinc-900/[0.03] dark:border-white/20 dark:bg-white/[0.03]\" : \"\"\n              } ${tip && tip.index !== i ? \"opacity-55\" : \"opacity-100\"}`}\n              style={{ ...barStyle(d.status), borderRadius: radius }}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div aria-hidden=\"true\" className=\"mt-2 flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\">\n        <span className=\"shrink-0\">{visible[0] ? formatDate(visible[0].date) : \"\"}</span>\n        <span className=\"h-px flex-1 bg-zinc-900/10 dark:bg-white/10\" />\n        <span className=\"shrink-0\">\n          {count} {count === 1 ? \"day\" : \"days\"}\n        </span>\n        <span className=\"h-px flex-1 bg-zinc-900/10 dark:bg-white/10\" />\n        <span className=\"shrink-0\">{latest ? formatDate(latest.date) : \"\"}</span>\n      </div>\n\n      {showLegend && (\n        <ul className=\"mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\">\n          {([\"up\", \"degraded\", \"down\", \"none\"] as const).map((status) => (\n            <li key={status} className=\"flex items-center gap-1.5\">\n              {dot(status, \"h-3 w-1.5\")}\n              {STATUS_TEXT[status]}\n            </li>\n          ))}\n        </ul>\n      )}\n\n      {tip && tipDay && (\n        <div\n          role=\"presentation\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute z-10 w-max max-w-[220px] -translate-x-1/2 -translate-y-full rounded-md border border-zinc-900/10 bg-white px-2.5 py-2 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900\"\n          style={{ left: tip.x, top: tip.y - 8 }}\n        >\n          <span className=\"block font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400\">{formatDate(tipDay.date)}</span>\n          <span className=\"mt-1 flex items-center gap-1.5 font-medium\">\n            {dot(tipDay.status, \"h-3 w-1.5\")}\n            {STATUS_TEXT[tipDay.status]}\n            {tipDay.uptime !== undefined && (\n              <span className=\"ml-auto pl-2 font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400\">{formats.percent.format(tipDay.uptime)}%</span>\n            )}\n          </span>\n          {tipDay.note && <span className=\"mt-1 block leading-snug text-zinc-600 dark:text-zinc-300\">{tipDay.note}</span>}\n          <span\n            className=\"absolute top-full size-2 -translate-x-1/2 -translate-y-1 rotate-45 border-b border-r border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\"\n            style={{ left: `calc(50% + ${tip.arrow}px)` }}\n          />\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/uptime-bar"
}
