{
  "name": "uptime-card",
  "type": "registry:component",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Uptime Card\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { CircleCheck, TriangleAlert, X } from \"lucide-react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// How much wider the bar under the pointer gets, and how far the swell\n// spreads, in bars.\nconst MAGNIFY = 5;\nconst SPREAD = 2.4;\n// Bars ease toward their width at this rate, per second.\nconst EASE = 18;\nconst DAY = 86_400_000;\n\nexport type DayStatus = \"up\" | \"degraded\" | \"down\" | \"none\";\n\nexport interface IncidentUpdate {\n  /** HH:MM, local to the status page. */\n  time: string;\n  label: string;\n  note?: string;\n}\n\nexport interface Incident {\n  title: string;\n  status: Exclude<DayStatus, \"up\" | \"none\">;\n  /** Minutes of impact that day. */\n  minutes: number;\n  updates: IncidentUpdate[];\n}\n\nexport interface UptimeService {\n  name: string;\n  /** Incidents by day index, 0 being the oldest day shown. */\n  incidents?: Record<number, Incident>;\n  /** Days with no data (before the service existed), from the start. */\n  missing?: number;\n}\n\nexport interface UptimeCardProps {\n  title?: string;\n  services?: UptimeService[];\n  days?: number;\n  /** The last day shown, as YYYY-MM-DD. */\n  endDate?: string;\n  className?: string;\n}\n\nconst DEFAULT_SERVICES: UptimeService[] = [\n  {\n    name: \"API\",\n    incidents: {\n      19: {\n        title: \"Elevated error rates on writes\",\n        status: \"degraded\",\n        minutes: 18,\n        updates: [\n          {\n            time: \"14:02\",\n            label: \"Investigating\",\n            note: \"5xx on POST rose to 2%.\",\n          },\n          {\n            time: \"14:11\",\n            label: \"Identified\",\n            note: \"A bad config push to one region.\",\n          },\n          {\n            time: \"14:20\",\n            label: \"Resolved\",\n            note: \"Rolled back; error rates normal.\",\n          },\n        ],\n      },\n      84: {\n        title: \"Slow responses in eu-west\",\n        status: \"degraded\",\n        minutes: 23,\n        updates: [\n          {\n            time: \"09:41\",\n            label: \"Investigating\",\n            note: \"p95 latency up 3x in eu-west.\",\n          },\n          {\n            time: \"09:52\",\n            label: \"Monitoring\",\n            note: \"Added capacity to the pool.\",\n          },\n          { time: \"10:04\", label: \"Resolved\" },\n        ],\n      },\n    },\n  },\n  {\n    name: \"Dashboard\",\n    incidents: {\n      43: {\n        title: \"Charts failed to load\",\n        status: \"degraded\",\n        minutes: 12,\n        updates: [\n          { time: \"18:30\", label: \"Investigating\" },\n          {\n            time: \"18:42\",\n            label: \"Resolved\",\n            note: \"A CDN cache rule was reverted.\",\n          },\n        ],\n      },\n    },\n  },\n  {\n    name: \"Webhooks\",\n    incidents: {\n      69: {\n        title: \"Deliveries delayed\",\n        status: \"down\",\n        minutes: 48,\n        updates: [\n          {\n            time: \"02:15\",\n            label: \"Investigating\",\n            note: \"The delivery queue stopped draining.\",\n          },\n          {\n            time: \"02:38\",\n            label: \"Identified\",\n            note: \"A worker deploy failed health checks.\",\n          },\n          {\n            time: \"03:03\",\n            label: \"Resolved\",\n            note: \"Backlog delivered in order.\",\n          },\n        ],\n      },\n    },\n  },\n  {\n    name: \"Database\",\n    missing: 6,\n    incidents: {\n      27: {\n        title: \"Failover to replica\",\n        status: \"down\",\n        minutes: 9,\n        updates: [\n          {\n            time: \"22:07\",\n            label: \"Investigating\",\n            note: \"Primary stopped accepting writes.\",\n          },\n          {\n            time: \"22:16\",\n            label: \"Resolved\",\n            note: \"Promoted the replica; no data lost.\",\n          },\n        ],\n      },\n    },\n  },\n];\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\nconst formatDay = (ms: number) =>\n  new Date(ms).toLocaleDateString(\"en-US\", {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    timeZone: \"UTC\",\n  });\n\nfunction statusOf(service: UptimeService, day: number): DayStatus {\n  if (day < (service.missing ?? 0)) {\n    return \"none\";\n  }\n  return service.incidents?.[day]?.status ?? \"up\";\n}\n\n/** Downtime counts in full, degraded time at half. */\nfunction uptimeOf(service: UptimeService, days: number) {\n  const tracked = days - (service.missing ?? 0);\n  let lost = 0;\n  for (const incident of Object.values(service.incidents ?? {})) {\n    lost += incident.minutes * (incident.status === \"down\" ? 1 : 0.5);\n  }\n  return 100 * (1 - lost / (tracked * 1440));\n}\n\n/** Target widths: a swell around the focus, flat without one. */\nfunction swell(count: number, focus: number | null, out: Float64Array) {\n  for (let i = 0; i < count; i++) {\n    out[i] =\n      focus === null\n        ? 1\n        : 1 + MAGNIFY * Math.exp(-(((i - focus) / SPREAD) ** 2));\n  }\n  return out;\n}\n\n/** The bar under x (0 to 1) when bars have these widths. */\nfunction barAt(weights: Float64Array, x: number) {\n  let total = 0;\n  for (const w of weights) {\n    total += w;\n  }\n  let at = x * total;\n  for (let i = 0; i < weights.length; i++) {\n    at -= weights[i];\n    if (at <= 0) {\n      return i;\n    }\n  }\n  return weights.length - 1;\n}\n\nconst BAR_COLOR: Record<DayStatus, string> = {\n  up: \"bg-emerald-500 dark:bg-emerald-400\",\n  degraded: \"bg-amber-400 dark:bg-amber-300\",\n  down: \"bg-rose-500 dark:bg-rose-400\",\n  none: \"bg-zinc-200 dark:bg-zinc-800\",\n};\n\nconst STATUS_TEXT: Record<DayStatus, string> = {\n  up: \"Operational\",\n  degraded: \"Degraded\",\n  down: \"Outage\",\n  none: \"No data\",\n};\n\nexport default function UptimeCard({\n  title = \"System status\",\n  services = DEFAULT_SERVICES,\n  days = 90,\n  endDate = \"2026-10-14\",\n  className,\n}: UptimeCardProps) {\n  const [active, setActive] = useState<number | null>(null);\n  const [pinned, setPinned] = useState<number | null>(null);\n  const barsRef = useRef<HTMLDivElement>(null);\n  const sim = useRef({\n    weights: new Float64Array(days).fill(1),\n    target: new Float64Array(days).fill(1),\n    focus: null as number | null,\n    raf: 0,\n    last: 0,\n    reduceMotion: false,\n  });\n  if (sim.current.weights.length !== days) {\n    sim.current.weights = new Float64Array(days).fill(1);\n    sim.current.target = new Float64Array(days).fill(1);\n  }\n\n  const end = Date.parse(`${endDate}T00:00:00Z`);\n  const dateOf = useCallback(\n    (day: number) => end - (days - 1 - day) * DAY,\n    [end, days]\n  );\n  const uptimes = useMemo(\n    () => services.map((s) => uptimeOf(s, days)),\n    [services, days]\n  );\n  const openIssues = services.filter(\n    (s) => statusOf(s, days - 1) !== \"up\" && statusOf(s, days - 1) !== \"none\"\n  );\n\n  // Widths are written straight to the bars, one flex-grow per bar.\n  const paint = useCallback(() => {\n    const rows = barsRef.current?.querySelectorAll<HTMLElement>(\"[data-row]\");\n    const { weights } = sim.current;\n    for (const row of rows ?? []) {\n      const bars = row.children;\n      for (let i = 0; i < bars.length; i++) {\n        (bars[i] as HTMLElement).style.flexGrow = weights[i].toFixed(3);\n      }\n    }\n  }, []);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const dt = s.last ? Math.min((now - s.last) / 1000, 1 / 30) : 1 / 60;\n      s.last = now;\n      const k = 1 - Math.exp(-EASE * dt);\n      let moving = false;\n      for (let i = 0; i < s.weights.length; i++) {\n        const gap = s.target[i] - s.weights[i];\n        if (Math.abs(gap) > 0.002) {\n          s.weights[i] += gap * k;\n          moving = true;\n        } else {\n          s.weights[i] = s.target[i];\n        }\n      }\n      paint();\n      if (moving) {\n        s.raf = requestAnimationFrame(tick);\n      } else {\n        s.last = 0;\n      }\n    },\n    [paint]\n  );\n\n  const focusOn = useCallback(\n    (focus: number | null) => {\n      const s = sim.current;\n      s.focus = focus;\n      swell(s.weights.length, focus, s.target);\n      if (s.reduceMotion) {\n        s.weights.set(s.target);\n        paint();\n      } else if (!s.raf) {\n        s.raf = requestAnimationFrame(tick);\n      }\n    },\n    [paint, tick]\n  );\n\n  useEffect(() => {\n    const s = sim.current;\n    s.reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    return () => cancelAnimationFrame(s.raf);\n  }, []);\n\n  /** The day under the pointer, found against the swollen layout. */\n  const dayAt = (clientX: number) => {\n    const rect = barsRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return 0;\n    }\n    const x = clamp((clientX - rect.left) / rect.width, 0, 1);\n    const probe = new Float64Array(days);\n    let day = Math.round(x * (days - 1));\n    // Centre the swell on the bar that ends up under the pointer.\n    for (let pass = 0; pass < 3; pass++) {\n      day = barAt(swell(days, day, probe), x);\n    }\n    return day;\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (\n      event.pointerType !== \"mouse\" &&\n      !event.currentTarget.hasPointerCapture(event.pointerId)\n    ) {\n      return;\n    }\n    const day = dayAt(event.clientX);\n    setActive(day);\n    focusOn(day);\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    if (event.pointerType !== \"mouse\") {\n      event.currentTarget.setPointerCapture(event.pointerId);\n    }\n    const day = dayAt(event.clientX);\n    setActive(day);\n    focusOn(day);\n  };\n\n  const handleClick = () => {\n    if (active !== null) {\n      setPinned((p) => (p === active ? null : active));\n    }\n  };\n\n  const leave = () => {\n    setActive(null);\n    focusOn(pinned);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const from = active ?? pinned ?? days - 1;\n    const moves: Record<string, number> = {\n      ArrowLeft: -1,\n      ArrowRight: 1,\n      Home: -days,\n      End: days,\n    };\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      setPinned((p) => (p === from ? null : from));\n      return;\n    }\n    if (event.key === \"Escape\") {\n      setPinned(null);\n      leave();\n      return;\n    }\n    const move = moves[event.key];\n    if (move === undefined) {\n      return;\n    }\n    event.preventDefault();\n    const day = clamp(from + move, 0, days - 1);\n    setActive(day);\n    focusOn(day);\n  };\n\n  const shownDay = active ?? pinned;\n  const shownIncidents =\n    shownDay === null\n      ? []\n      : services.flatMap((s) => {\n          const incident = s.incidents?.[shownDay];\n          return incident ? [{ service: s.name, incident }] : [];\n        });\n  let summary = \"Hover a day to see what happened\";\n  if (shownDay !== null) {\n    const first = shownIncidents[0];\n    const what = first\n      ? `${first.service} ${first.incident.status === \"down\" ? \"down\" : \"degraded\"} for ${first.incident.minutes} min${shownIncidents.length > 1 ? `, +${shownIncidents.length - 1} more` : \"\"}`\n      : \"No incidents\";\n    summary = `${formatDay(dateOf(shownDay))} · ${what}`;\n  }\n  const pinnedIncidents =\n    pinned === null\n      ? []\n      : services.flatMap((s) => {\n          const incident = s.incidents?.[pinned];\n          return incident ? [{ service: s.name, incident }] : [];\n        });\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-xl select-none rounded-3xl border border-zinc-200 bg-white p-5 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-4\">\n        <p className=\"font-medium text-sm\">{title}</p>\n        {openIssues.length ? (\n          <p className=\"flex items-center gap-1.5 text-amber-600 text-sm dark:text-amber-400\">\n            <TriangleAlert aria-hidden className=\"size-4\" />\n            {openIssues.length} service{openIssues.length > 1 ? \"s\" : \"\"}{\" \"}\n            affected\n          </p>\n        ) : (\n          <p className=\"flex items-center gap-1.5 text-emerald-600 text-sm dark:text-emerald-400\">\n            <CircleCheck aria-hidden className=\"size-4\" />\n            All systems operational\n          </p>\n        )}\n      </div>\n      <p\n        aria-live=\"polite\"\n        className=\"mt-1 truncate text-sm text-zinc-500 tabular-nums dark:text-zinc-400\"\n      >\n        {summary}\n      </p>\n\n      <div\n        aria-label={`Daily status for the last ${days} days. Use the arrow keys to move between days and Enter to open one.`}\n        aria-valuemax={days}\n        aria-valuemin={1}\n        aria-valuenow={(shownDay ?? days - 1) + 1}\n        aria-valuetext={summary}\n        className=\"mt-4 cursor-pointer touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4 dark:focus-visible:ring-offset-zinc-950\"\n        onClick={handleClick}\n        onKeyDown={handleKeyDown}\n        onLostPointerCapture={leave}\n        onPointerDown={handlePointerDown}\n        onPointerLeave={(event) => {\n          if (event.pointerType === \"mouse\") {\n            leave();\n          }\n        }}\n        onPointerMove={handlePointerMove}\n        ref={barsRef}\n        role=\"slider\"\n        tabIndex={0}\n      >\n        {services.map((service, row) => (\n          <div className={cn(row > 0 && \"mt-3\")} key={service.name}>\n            <div className=\"mb-1.5 flex justify-between text-xs\">\n              <span className=\"font-medium\">{service.name}</span>\n              <span className=\"text-zinc-500 tabular-nums dark:text-zinc-400\">\n                {uptimes[row].toFixed(2)}%\n              </span>\n            </div>\n            <div className=\"flex h-7 gap-px sm:gap-[2px]\" data-row>\n              {Array.from({ length: days }, (_, day) => {\n                const status = statusOf(service, day);\n                const lit = day === shownDay;\n                return (\n                  <div\n                    className={cn(\n                      \"min-w-0 basis-0 rounded-[2px] transition-opacity duration-150 ease-out\",\n                      BAR_COLOR[status],\n                      shownDay !== null && !lit && \"opacity-60\"\n                    )}\n                    // biome-ignore lint/suspicious/noArrayIndexKey: one bar per day, never reordered\n                    key={day}\n                    style={{ flexGrow: 1 }}\n                  />\n                );\n              })}\n            </div>\n          </div>\n        ))}\n        <div className=\"mt-2 flex justify-between text-xs text-zinc-400 dark:text-zinc-500\">\n          <span>{days} days ago</span>\n          <span>Today</span>\n        </div>\n      </div>\n\n      {/* A pinned day opens its incident log. */}\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-[260ms] ease-[cubic-bezier(0.23,1,0.32,1)]\",\n          pinned === null\n            ? \"grid-rows-[0fr] opacity-0\"\n            : \"grid-rows-[1fr] opacity-100\"\n        )}\n      >\n        <div className=\"overflow-hidden\">\n          {pinned !== null && (\n            <div className=\"mt-4 rounded-2xl bg-zinc-50 p-4 dark:bg-zinc-900\">\n              <div className=\"flex items-center justify-between\">\n                <p className=\"font-medium text-sm\">\n                  {formatDay(dateOf(pinned))}\n                </p>\n                <button\n                  aria-label=\"Close day\"\n                  className=\"rounded-full p-1 text-zinc-500 outline-none transition-colors duration-150 ease-out hover:bg-zinc-200 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n                  onClick={() => {\n                    setPinned(null);\n                    focusOn(null);\n                  }}\n                  type=\"button\"\n                >\n                  <X aria-hidden className=\"size-3.5\" />\n                </button>\n              </div>\n              {pinnedIncidents.length === 0 && (\n                <p className=\"mt-1 text-sm text-zinc-500 dark:text-zinc-400\">\n                  No incidents recorded. Every service was operational.\n                </p>\n              )}\n              {pinnedIncidents.map(({ service, incident }) => (\n                <div className=\"mt-3\" key={service}>\n                  <p className=\"flex flex-wrap items-center gap-2 text-sm\">\n                    <span\n                      className={cn(\n                        \"rounded-full px-2 py-0.5 font-medium text-[11px]\",\n                        incident.status === \"down\"\n                          ? \"bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300\"\n                          : \"bg-amber-100 text-amber-800 dark:bg-amber-400/15 dark:text-amber-200\"\n                      )}\n                    >\n                      {STATUS_TEXT[incident.status]}\n                    </span>\n                    <span className=\"font-medium\">{service}</span>\n                    <span className=\"text-zinc-500 dark:text-zinc-400\">\n                      {incident.title} · {incident.minutes} min\n                    </span>\n                  </p>\n                  <ol className=\"mt-2.5 ml-1 border-zinc-200 border-l dark:border-zinc-800\">\n                    {incident.updates.map((update) => (\n                      <li\n                        className=\"relative pb-2.5 pl-4 text-sm last:pb-0\"\n                        key={`${update.time}-${update.label}`}\n                      >\n                        <span className=\"absolute top-[7px] -left-[3.5px] size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500\" />\n                        <span className=\"text-zinc-400 tabular-nums dark:text-zinc-500\">\n                          {update.time}\n                        </span>{\" \"}\n                        <span className=\"font-medium\">{update.label}</span>\n                        {update.note && (\n                          <span className=\"text-zinc-500 dark:text-zinc-400\">\n                            {\" \"}\n                            {update.note}\n                          </span>\n                        )}\n                      </li>\n                    ))}\n                  </ol>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/uptime-card.tsx",
      "target": "components/kokonutui/uptime-card.tsx"
    }
  ]
}