{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "status-calendar",
  "title": "Status Calendar",
  "description": "Monthly calendar view of status history with per-day markers and event hover cards",
  "dependencies": [
    "date-fns",
    "lucide-react",
    "react-day-picker@8"
  ],
  "registryDependencies": [
    "https://openstatus.dev/r/status-types.json",
    "https://openstatus.dev/r/status-i18n.json",
    "https://openstatus.dev/r/status-bar.json",
    "button",
    "hover-card",
    "separator",
    "skeleton",
    "https://openstatus.dev/r/use-media-query.json"
  ],
  "files": [
    {
      "path": "src/components/blocks/status-calendar.tsx",
      "content": "\"use client\";\n\nimport { StatusBarCard } from \"@/components/blocks/status-bar\";\nimport { useStatusBlocksLabels } from \"@/components/blocks/status-i18n\";\nimport type {\n  StatusBarData,\n  StatusEventType,\n  StatusType,\n} from \"@/components/blocks/status.types\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { useMediaQuery } from \"@/hooks/use-media-query\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  type Locale,\n  addMonths,\n  format,\n  isSameDay,\n  startOfDay,\n  startOfMonth,\n  subMonths,\n} from \"date-fns\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  type FocusEvent,\n  type ReactNode,\n  type RefObject,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { DayPicker, type DayProps } from \"react-day-picker\";\n\ntype BarEvent = StatusBarData[\"events\"][number];\n\nexport interface StatusCalendarMarker {\n  id: string | number;\n  /** Day this marker belongs to, in the viewer's local timezone. */\n  date: Date;\n  /** Drives the day cell's tinted-fill color (worst-severity wins per day). */\n  status: Exclude<StatusType, \"empty\">;\n  /** Event shape mirrors StatusBarEvent so the popover can render the same row. */\n  type: StatusEventType;\n  name: string;\n  /** Full event range (use the event's true start/end, not the per-day instance). */\n  from?: Date | null;\n  to?: Date | null;\n  isAggregated?: boolean;\n  href?: string;\n}\n\nexport interface StatusCalendarProps {\n  markers: StatusCalendarMarker[];\n  /** Controlled month. Defaults to the first of the current month. */\n  month?: Date;\n  defaultMonth?: Date;\n  onMonthChange?: (month: Date) => void;\n  /** 0 = Sunday, 1 = Monday. Mockup shows Monday. */\n  weekStartsOn?: 0 | 1;\n  /**\n   * Treat the calendar as status history: render days after the latest event\n   * (or today, whichever is later) as disabled. Days up to and including that\n   * boundary — including scheduled future events — stay interactive. Forward\n   * navigation is still capped at the latest event's month. Defaults to false.\n   */\n  disableFuture?: boolean;\n  className?: string;\n  /** Calendar header label on the left side of the chrome. */\n  title?: ReactNode;\n  /**\n   * date-fns Locale used by DayPicker for weekday/month names. Callers should\n   * pass the locale resolved from their i18n layer; defaults to en-US.\n   */\n  locale?: Locale;\n  /** Override how a single marker row renders inside the hover card. */\n  renderMarkerRow?: (marker: StatusCalendarMarker) => ReactNode;\n  /**\n   * Restrict which event types drive the calendar (day fills, hover cards, and\n   * the forward-navigation cap). Markers of any other type are ignored. When\n   * omitted, all event types are shown.\n   */\n  eventTypes?: StatusEventType[];\n}\n\nconst SEVERITY_RANK: Record<StatusCalendarMarker[\"status\"], number> = {\n  error: 3,\n  degraded: 2,\n  info: 1,\n  success: 0,\n};\n\n// Hover deepens the same tint rather than swapping to the neutral accent, so\n// the severity signal survives the hover state.\nconst SEVERITY_FILL: Record<StatusCalendarMarker[\"status\"], string> = {\n  error: \"bg-destructive/10 hover:bg-destructive/20 text-destructive\",\n  degraded: \"bg-warning/10 hover:bg-warning/20 text-warning\",\n  info: \"bg-info/10 hover:bg-info/20 text-info\",\n  success: \"bg-success/10 hover:bg-success/20 text-success\",\n};\n\nfunction dayKey(date: Date): string {\n  return format(date, \"yyyy-MM-dd\");\n}\n\nfunction worstSeverity(\n  markers: StatusCalendarMarker[],\n): StatusCalendarMarker[\"status\"] {\n  return markers.reduce<StatusCalendarMarker[\"status\"]>((worst, m) => {\n    return SEVERITY_RANK[m.status] > SEVERITY_RANK[worst] ? m.status : worst;\n  }, \"success\");\n}\n\nexport function StatusCalendar({\n  markers,\n  month,\n  defaultMonth,\n  onMonthChange,\n  weekStartsOn = 1,\n  disableFuture = false,\n  className,\n  title,\n  locale,\n  renderMarkerRow,\n  eventTypes,\n}: StatusCalendarProps) {\n  const labels = useStatusBlocksLabels();\n  const resolvedTitle = title ?? labels.calendarTitle;\n  const formatMonthYear = useCallback(\n    (d: Date) => {\n      // date-fns format reads its global locale (set by DateFnsProvider via\n      // setDefaultOptions), so this picks up the active language without us\n      // having to pass it explicitly here.\n      return format(d, \"MMM yyyy\", locale ? { locale } : undefined);\n    },\n    [locale],\n  );\n  const [internalMonth, setInternalMonth] = useState<Date>(() =>\n    startOfMonth(month ?? defaultMonth ?? new Date()),\n  );\n  const currentMonth = month ? startOfMonth(month) : internalMonth;\n\n  const setMonth = (next: Date) => {\n    const normalized = startOfMonth(next);\n    if (month === undefined) setInternalMonth(normalized);\n    onMonthChange?.(normalized);\n  };\n\n  // Drop markers whose type the caller opted out of before anything else reads\n  // them, so excluded types affect neither the fills, the cards, nor nav.\n  const visibleMarkers = useMemo(() => {\n    if (!eventTypes) return markers;\n    const allowed = new Set(eventTypes);\n    return markers.filter((m) => allowed.has(m.type));\n  }, [markers, eventTypes]);\n\n  const markersByDay = useMemo(() => {\n    const map = new Map<string, StatusCalendarMarker[]>();\n    for (const marker of visibleMarkers) {\n      const key = dayKey(marker.date);\n      const bucket = map.get(key);\n      if (bucket) bucket.push(marker);\n      else map.set(key, [marker]);\n    }\n    return map;\n  }, [visibleMarkers]);\n\n  // Cap forward navigation at the latest month that has a marker (or the\n  // current month, whichever is later). Prevents browsing into empty future\n  // months when no maintenance/event is scheduled.\n  const maxMonth = useMemo(() => {\n    const today = startOfMonth(new Date());\n    return visibleMarkers.reduce((acc, m) => {\n      const mm = startOfMonth(m.date);\n      return mm.getTime() > acc.getTime() ? mm : acc;\n    }, today);\n  }, [visibleMarkers]);\n  const canGoNext = currentMonth.getTime() < maxMonth.getTime();\n\n  // Status-history cutoff: disable days strictly after the latest event (or\n  // today, whichever is later) so the empty trailing future is blocked while\n  // scheduled future events stay reachable. Null = no blocking.\n  const maxDay = useMemo(() => {\n    if (!disableFuture) return null;\n    const today = startOfDay(new Date());\n    return visibleMarkers.reduce((acc, m) => {\n      const d = startOfDay(m.date);\n      return d.getTime() > acc.getTime() ? d : acc;\n    }, today);\n  }, [visibleMarkers, disableFuture]);\n\n  // Open state lives at the parent so the popover survives DayPicker remounts.\n  // Interaction model mirrors `status-bar.tsx` (hover on non-touch, tap pins).\n  const isTouch = useMediaQuery(\"(hover: none)\");\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [activeDayKey, setActiveDayKey] = useState<string | null>(null);\n  // Via ref, not a `Day` dep: keeps `Day` identity stable so cells don't remount\n  // every hover. CalendarDay re-renders top-down on setActiveDayKey regardless.\n  const activeDayKeyRef = useRef(activeDayKey);\n  useEffect(() => {\n    activeDayKeyRef.current = activeDayKey;\n  }, [activeDayKey]);\n  const [interaction, setInteraction] = useState<\n    \"hover\" | \"pin\" | \"focus\" | null\n  >(null);\n  const interactionRef = useRef(interaction);\n  useEffect(() => {\n    interactionRef.current = interaction;\n  }, [interaction]);\n\n  const closeTimerRef = useRef<number | null>(null);\n  const cancelClose = useCallback(() => {\n    if (closeTimerRef.current !== null) {\n      window.clearTimeout(closeTimerRef.current);\n      closeTimerRef.current = null;\n    }\n  }, []);\n  const close = useCallback(() => {\n    cancelClose();\n    setActiveDayKey(null);\n    setInteraction(null);\n  }, [cancelClose]);\n\n  // Tap/click pins the card; tapping the pinned day again closes it. This is\n  // the only open path on touch — Radix HoverCard ignores touch pointers. The\n  // `interactionRef` lags a tap's focus event, so a focus-then-click on the\n  // same day still reads \"pin\" and toggles closed.\n  const handleClick = useCallback(\n    (key: string) => {\n      cancelClose();\n      setActiveDayKey((prev) => {\n        if (prev === key && interactionRef.current === \"pin\") {\n          setInteraction(null);\n          return null;\n        }\n        setInteraction(\"pin\");\n        return key;\n      });\n    },\n    [cancelClose],\n  );\n  const handleHoverStart = useCallback(\n    (key: string) => {\n      if (isTouch) return;\n      cancelClose();\n      setActiveDayKey(key);\n      setInteraction(\"hover\");\n    },\n    [isTouch, cancelClose],\n  );\n  const handleHoverEnd = useCallback(() => {\n    if (interactionRef.current !== \"hover\") return;\n    closeTimerRef.current = window.setTimeout(() => {\n      setActiveDayKey(null);\n      setInteraction(null);\n    }, 100);\n  }, []);\n  const handleFocus = useCallback((key: string) => {\n    setActiveDayKey(key);\n    setInteraction(\"focus\");\n  }, []);\n  const handleBlur = useCallback(\n    (e: FocusEvent) => {\n      const next = e.relatedTarget as Element | null;\n      // Focus moving into the card (e.g. an event link) shouldn't close it.\n      if (next?.closest('[data-slot=\"status-bar-card\"]')) return;\n      close();\n    },\n    [close],\n  );\n\n  // Outside pointerdown closes the pinned card. Radix's own dismiss is\n  // suppressed on touch (see HoverCardContent) and the card is portaled out of\n  // `containerRef`, so we also keep clicks that land inside the card itself.\n  useEffect(() => {\n    if (activeDayKey === null) return;\n    const onDown = (e: MouseEvent) => {\n      const target = e.target as Element | null;\n      if (containerRef.current?.contains(target)) return;\n      if (target?.closest('[data-slot=\"status-bar-card\"]')) return;\n      close();\n    };\n    document.addEventListener(\"mousedown\", onDown);\n    return () => document.removeEventListener(\"mousedown\", onDown);\n  }, [activeDayKey, close]);\n\n  const renderMarkerRowRef = useRef(renderMarkerRow);\n  useEffect(() => {\n    renderMarkerRowRef.current = renderMarkerRow;\n  }, [renderMarkerRow]);\n\n  const Day = useCallback(\n    (dayProps: DayProps) => (\n      <CalendarDay\n        {...dayProps}\n        markersByDay={markersByDay}\n        activeDayKeyRef={activeDayKeyRef}\n        isTouch={isTouch}\n        onClickDay={handleClick}\n        onHoverStart={handleHoverStart}\n        onHoverEnd={handleHoverEnd}\n        onHoverCardEnter={cancelClose}\n        onHoverCardLeave={close}\n        onFocusDay={handleFocus}\n        onBlurDay={handleBlur}\n        onClose={close}\n        renderMarkerRowRef={renderMarkerRowRef}\n        maxDay={maxDay}\n      />\n    ),\n    [\n      markersByDay,\n      isTouch,\n      handleClick,\n      handleHoverStart,\n      handleHoverEnd,\n      cancelClose,\n      close,\n      handleFocus,\n      handleBlur,\n      maxDay,\n    ],\n  );\n\n  const dayPickerComponents = useMemo(() => ({ Day }), [Day]);\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"status-calendar\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col rounded-lg border\",\n        className,\n      )}\n    >\n      <header className=\"flex items-center gap-3 px-4 py-3\">\n        <div className=\"text-sm font-medium\">{resolvedTitle}</div>\n        <div className=\"text-muted-foreground ml-auto flex items-center gap-1 text-sm\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-7\"\n            aria-label=\"Previous month\"\n            onClick={() => setMonth(subMonths(currentMonth, 1))}\n          >\n            <ChevronLeft className=\"size-4\" />\n          </Button>\n          <span className=\"text-foreground text-center font-mono font-medium tabular-nums\">\n            {formatMonthYear(currentMonth)}\n          </span>\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"size-7\"\n            aria-label=\"Next month\"\n            disabled={!canGoNext}\n            onClick={() => setMonth(addMonths(currentMonth, 1))}\n          >\n            <ChevronRight className=\"size-4\" />\n          </Button>\n        </div>\n      </header>\n      <div className=\"border-t\">\n        <DayPicker\n          mode=\"default\"\n          month={currentMonth}\n          weekStartsOn={weekStartsOn}\n          showOutsideDays={false}\n          locale={locale}\n          className=\"w-full\"\n          classNames={{\n            months: \"flex flex-col\",\n            month: \"flex flex-col w-full\",\n            caption: \"hidden\",\n            table: \"w-full border-collapse\",\n            head_row: \"flex w-full border-b\",\n            head_cell:\n              \"flex-1 text-muted-foreground font-mono font-normal text-[0.7rem] uppercase tracking-wide py-1.5 border-r last:border-r-0\",\n            row: \"flex w-full border-b last:border-b-0\",\n            cell: \"flex-1 relative p-0 text-center text-sm border-r last:border-r-0 focus-within:relative focus-within:z-20\",\n            day: cn(\n              \"text-foreground/80 inline-flex h-12 w-full items-center justify-center text-sm font-normal transition-colors\",\n              \"hover:bg-accent hover:text-accent-foreground\",\n              \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset\",\n            ),\n            day_today: \"text-foreground font-semibold\",\n            day_outside: \"text-muted-foreground/50\",\n            day_disabled: \"text-muted-foreground/40 cursor-not-allowed\",\n            day_hidden: \"invisible\",\n          }}\n          components={dayPickerComponents}\n        />\n      </div>\n    </div>\n  );\n}\n\ninterface CalendarDayProps extends DayProps {\n  markersByDay: Map<string, StatusCalendarMarker[]>;\n  activeDayKeyRef: RefObject<string | null>;\n  isTouch: boolean;\n  onClickDay: (key: string) => void;\n  onHoverStart: (key: string) => void;\n  onHoverEnd: () => void;\n  onHoverCardEnter: () => void;\n  onHoverCardLeave: () => void;\n  onFocusDay: (key: string) => void;\n  onBlurDay: (e: FocusEvent) => void;\n  onClose: () => void;\n  renderMarkerRowRef: RefObject<\n    ((marker: StatusCalendarMarker) => ReactNode) | undefined\n  >;\n  /** Days strictly after this are disabled; null disables blocking entirely. */\n  maxDay: Date | null;\n}\n\nconst CalendarDay = forwardRef<HTMLElement, CalendarDayProps>(\n  function CalendarDay(\n    {\n      date,\n      displayMonth,\n      markersByDay,\n      activeDayKeyRef,\n      isTouch,\n      onClickDay,\n      onHoverStart,\n      onHoverEnd,\n      onHoverCardEnter,\n      onHoverCardLeave,\n      onFocusDay,\n      onBlurDay,\n      onClose,\n      renderMarkerRowRef,\n      maxDay,\n    },\n    forwardedRef,\n  ) {\n    // With `mode=\"default\"`, useDayRender returns `isButton: false` for every\n    // day — DayPicker only generates click handlers under a selection mode.\n    // We don't need DayPicker's interactivity (we drive our own hover popover),\n    // so we skip useDayRender entirely and render our own trigger.\n    const thisDayKey = dayKey(startOfDay(date));\n    const isOutside =\n      startOfMonth(date).getTime() !== startOfMonth(displayMonth).getTime();\n\n    // Overriding `components.Day` bypasses DayPicker's own hide-outside-days\n    // logic — we have to drop these cells ourselves so the grid only shows the\n    // current month.\n    if (isOutside) {\n      return (\n        <div data-day-state=\"outside\" className=\"bg-muted/30 h-12 w-full\" />\n      );\n    }\n\n    // Past the status-history cutoff: render a dimmed, non-interactive cell.\n    const isBlocked =\n      maxDay !== null && startOfDay(date).getTime() > maxDay.getTime();\n    if (isBlocked) {\n      return (\n        <div\n          ref={forwardedRef as RefObject<HTMLDivElement>}\n          data-day={thisDayKey}\n          data-day-state=\"disabled\"\n          aria-disabled\n          tabIndex={-1}\n          className=\"text-muted-foreground/40 inline-flex h-12 w-full items-center justify-center font-mono text-sm\"\n        >\n          {format(date, \"d\")}\n        </div>\n      );\n    }\n\n    const isToday = isSameDay(date, new Date());\n    const dayMarkers = markersByDay.get(thisDayKey) ?? [];\n    const severity =\n      dayMarkers.length > 0 ? worstSeverity(dayMarkers) : undefined;\n    const open = activeDayKeyRef.current === thisDayKey;\n\n    const hasMarkers = dayMarkers.length > 0;\n    // Tinted-field: severity days get a soft fill that owns the hover state,\n    // empty days fall back to the neutral accent hover.\n    const dayClass = cn(\n      \"text-foreground/80 inline-flex h-12 w-full items-center justify-center font-mono text-sm font-normal transition-colors\",\n      \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset\",\n      isToday && \"text-foreground font-semibold\",\n      severity\n        ? SEVERITY_FILL[severity]\n        : \"hover:bg-accent hover:text-accent-foreground\",\n      hasMarkers && \"cursor-pointer\",\n    );\n\n    // Days without events render a plain trigger — no HoverCard, no popover.\n    if (!hasMarkers) {\n      return (\n        <div\n          ref={forwardedRef as RefObject<HTMLDivElement>}\n          data-day={thisDayKey}\n          data-day-state=\"empty\"\n          tabIndex={-1}\n          className={dayClass}\n        >\n          {format(date, \"d\")}\n        </div>\n      );\n    }\n\n    const barItem: StatusBarData = {\n      day: format(date, \"yyyy-MM-dd\"),\n      bar: [],\n      card: [],\n      events: dayMarkers.map(markerToBarEvent),\n    };\n    const indexByEventId = new Map<BarEvent[\"id\"], number>(\n      barItem.events.map((e, i) => [e.id, i]),\n    );\n\n    return (\n      // No `onOpenChange`: open state is fully controlled by our handlers (see\n      // parent), matching `status-bar.tsx`. Radix's hover/dismiss listeners fire\n      // into a no-op, so they can't fight the pin/toggle model.\n      <HoverCard openDelay={0} closeDelay={0} open={open}>\n        <HoverCardTrigger asChild>\n          <button\n            type=\"button\"\n            ref={forwardedRef as RefObject<HTMLButtonElement>}\n            data-day={thisDayKey}\n            data-day-state=\"interactive\"\n            aria-pressed={open}\n            className={dayClass}\n            onClick={() => onClickDay(thisDayKey)}\n            onMouseEnter={() => onHoverStart(thisDayKey)}\n            onMouseLeave={onHoverEnd}\n            onFocus={() => onFocusDay(thisDayKey)}\n            onBlur={onBlurDay}\n            onKeyDown={(e) => {\n              if (e.key === \"Escape\") onClose();\n            }}\n          >\n            {format(date, \"d\")}\n          </button>\n        </HoverCardTrigger>\n        <HoverCardContent\n          side=\"top\"\n          align=\"center\"\n          className=\"w-auto min-w-40 p-0\"\n          onMouseEnter={onHoverCardEnter}\n          onMouseLeave={onHoverCardLeave}\n          // On touch, the opening tap's emulated pointer sequence would trip\n          // Radix's dismiss-on-outside and close the card immediately. Suppress\n          // it here; the parent's document listener handles real outside taps.\n          onPointerDownOutside={(e) => {\n            if (isTouch) e.preventDefault();\n          }}\n        >\n          <StatusBarCard\n            item={barItem}\n            renderEvent={(event, eventIndex) => {\n              const fn = renderMarkerRowRef.current;\n              if (!fn) return undefined;\n              const idx = indexByEventId.get(event.id) ?? 0;\n              return (\n                <div key={`${event.id}-${event.type}`}>\n                  {eventIndex > 0 && (\n                    <Separator className=\"-mx-2 my-2 data-[orientation=horizontal]:w-auto\" />\n                  )}\n                  {fn(dayMarkers[idx])}\n                </div>\n              );\n            }}\n          />\n        </HoverCardContent>\n      </HoverCard>\n    );\n  },\n);\n\nfunction markerToBarEvent(m: StatusCalendarMarker): BarEvent {\n  return {\n    id: m.id,\n    name: m.name,\n    type: m.type,\n    from: m.from ?? null,\n    to: m.to ?? null,\n    isAggregated: m.isAggregated,\n  };\n}\n\nexport interface StatusCalendarSkeletonProps {\n  /** Title shown in the header chrome; falls back to \"Calendar\" if omitted. */\n  title?: ReactNode;\n  className?: string;\n}\n\n/** Loading placeholder mirroring `<StatusCalendar>`'s chrome (header + 6×7 grid). */\nexport function StatusCalendarSkeleton({\n  title,\n  className,\n}: StatusCalendarSkeletonProps) {\n  const labels = useStatusBlocksLabels();\n  return (\n    <div\n      data-slot=\"status-calendar-skeleton\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col rounded-lg border\",\n        className,\n      )}\n    >\n      <header className=\"flex items-center gap-3 px-4 py-3\">\n        <div className=\"text-sm font-medium\">\n          {title ?? labels.calendarTitle}\n        </div>\n        <div className=\"ml-auto flex items-center gap-1\">\n          <Skeleton className=\"size-7 rounded-md\" />\n          <Skeleton className=\"h-4 w-20\" />\n          <Skeleton className=\"size-7 rounded-md\" />\n        </div>\n      </header>\n      <div className=\"border-t\">\n        <div className=\"flex w-full border-b\">\n          {Array.from({ length: 7 }).map((_, i) => (\n            <div\n              key={`weekday-${i}`}\n              className=\"flex flex-1 justify-center border-r py-1.5 last:border-r-0\"\n            >\n              <Skeleton className=\"h-3 w-4\" />\n            </div>\n          ))}\n        </div>\n        {Array.from({ length: 6 }).map((_, row) => (\n          <div\n            key={`row-${row}`}\n            className=\"flex w-full border-b last:border-b-0\"\n          >\n            {Array.from({ length: 7 }).map((_, col) => (\n              <div\n                key={`cell-${row}-${col}`}\n                className=\"flex h-12 flex-1 items-center justify-center border-r last:border-r-0\"\n              >\n                <Skeleton className=\"h-4 w-6\" />\n              </div>\n            ))}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/blocks/status-calendar.tsx"
    }
  ],
  "type": "registry:block"
}