{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "event-calendar",
  "title": "Event Calendar",
  "author": "@fma-ui/ui",
  "description": "A headless-first event calendar: month, week, day, N-day, agenda and resource views, recurring events (structured rules or RRULE strings), time zones, drag to move/resize and drag to create (keyboard users create through `showDayAddButton`'s per-day buttons), i18n with RTL, and a subscribable store (`useEventCalendar`, `useEventCalendarSelector`). Compose `<EventCalendar>` with `EventCalendarNav` and `EventCalendarContent`, or build your own parts from the hooks. Events are controlled (`events`/`onEventsChange`) or not (`defaultEvents`); `onEventUpdate` accepts, rejects or adjusts each drag. The nav, view switcher, date picker and \"+N more\" popover use the registry's react-aria Button, Tooltip, DropdownMenu, Popover and Calendar.",
  "dependencies": [
    "cn@^0.4.0",
    "lucide-react@^1.52.0",
    "react-aria-components@^1.21.1",
    "@internationalized/date@^3.12.4"
  ],
  "registryDependencies": [
    "@fma-ui/icon-stack",
    "@fma-ui/scroll-area",
    "@fma-ui/tooltip",
    "@fma-ui/popover",
    "@fma-ui/button",
    "@fma-ui/calendar",
    "@fma-ui/dropdown-menu",
    "@fma-ui/date-fns-compat"
  ],
  "files": [
    {
      "path": "src/blocks/event-calendar/event-calendar-agenda-view.tsx",
      "content": "// Title: Event Calendar Agenda View\n// Description: Chronological agenda grouped by day - a day header row plus a clean time / dot / title table.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { useMemo } from \"react\";\nimport { IconStack } from \"@/core/icon-stack/icon-stack\";\nimport { ScrollArea } from \"@/core/scroll-area/scroll-area\";\nimport { addDays, format } from \"@/lib/date-fns-compat/index\";\nimport {\n  EventCalendarViewContext,\n  useEventCalendar,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarViewConfig,\n} from \"./event-calendar\";\nimport { EventCalendarEvent } from \"./event-calendar-event\";\nimport { getDayKey, getRangeKey, toZoned, zonedStartOfDay } from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type { EventCalendarDateRange, EventCalendarSegment } from \"./event-calendar-types\";\n\n// The agenda window length is the agendaDayCount SETTING (the store derives\n// visibleRange from it); a per-view prop here would silently disagree.\ntype EventCalendarAgendaViewProps = React.ComponentProps<\"div\">;\n\nfunction EventCalendarAgendaView({ className, ...props }: EventCalendarAgendaViewProps) {\n  const instance = useEventCalendar();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const visibleRange = useEventCalendarSelector<unknown, EventCalendarDateRange>(\n    (state) => state.visibleRange,\n    {\n      isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),\n    },\n  );\n  // Subscribe to event changes via the day-bucket content of the whole range\n  useEventCalendarSelector((state) => state.events);\n\n  const days = useMemo(() => {\n    const result: Date[] = [];\n    let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone);\n    while (cursor < visibleRange.end) {\n      result.push(cursor);\n      cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone);\n    }\n    return result;\n  }, [visibleRange, settings.timeZone]);\n\n  const index = instance.internals.getIndex();\n  const groups = days\n    .map((day) => ({\n      day,\n      bucket: index.byDay.get(getDayKey(day, settings.timeZone)),\n    }))\n    .filter((group) => {\n      const total = (group.bucket?.allDay.length ?? 0) + (group.bucket?.timed.length ?? 0);\n      return total > 0;\n    });\n\n  const isToday = (day: Date) =>\n    getDayKey(day, settings.timeZone) === getDayKey(new Date(), settings.timeZone);\n\n  const native = viewConfig.scrollbars === \"native\";\n\n  const body = (\n    <>\n      {groups.length === 0 ? (\n        <div\n          data-slot=\"event-calendar-no-events\"\n          className={cn(\n            \"flex min-h-72 flex-col items-center justify-center gap-4 py-16\",\n            viewConfig.classNames?.noEvents,\n          )}\n        >\n          {viewConfig.renderNoEvents?.() ?? (\n            <>\n              <IconStack>\n                <CalendarIcon className=\"size-5\" aria-hidden=\"true\" />\n              </IconStack>\n              <span className=\"text-muted-foreground text-sm\">{settings.i18n.labels.noEvents}</span>\n            </>\n          )}\n        </div>\n      ) : (\n        // Drop the very last row's bottom border so it does not double up with\n        // the calendar container's own bottom border. Targets the last day\n        // group's last child (its last agenda item); per-item `border-b` is\n        // kept everywhere else, including each day's internal rows.\n        <div className=\"flex flex-col [&>*:last-child>*:last-child]:border-b-0\">\n          {groups.map(({ day, bucket }) => {\n            const items = [...(bucket?.allDay ?? []), ...(bucket?.timed ?? [])];\n            const zoned = toZoned(day, settings.timeZone);\n            const weekday = format(zoned, \"EEEE\", { locale: settings.locale });\n            const dayDate = format(zoned, \"MMMM d, yyyy\", {\n              locale: settings.locale,\n            });\n            return (\n              <div\n                key={day.getTime()}\n                data-slot=\"event-calendar-agenda-day\"\n                data-today={isToday(day) || undefined}\n                // A named group per day so a screen reader can step day by day\n                // (and hear how full one is) instead of arrowing every row.\n                role=\"group\"\n                aria-label={`${weekday}, ${dayDate}, ${settings.i18n.labels.events(items.length)}`}\n              >\n                {/* Group header: weekday (leading) + full date (trailing) */}\n                <div\n                  data-slot=\"event-calendar-agenda-day-header\"\n                  // The day bar is the agenda's only structure, so give it a\n                  // heading level: the H key and the rotor can jump between\n                  // days, which is the whole point of a long agenda.\n                  role=\"heading\"\n                  aria-level={3}\n                  className={cn(\n                    \"bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2\",\n                    // The custom ScrollArea's overlay scrollbar (w-2.5 = 10px)\n                    // is painted UNDER this sticky, z-10, opaque header, so the\n                    // thumb vanishes behind the day bar at the top of the view.\n                    // Inset the header by the scrollbar lane so its background\n                    // stops before the scrollbar instead of covering it. Native\n                    // scrollbars already sit outside the content box, so this\n                    // only applies to the custom-scrollbar path.\n                    !native && \"me-2.5\",\n                    viewConfig.classNames?.agendaDayHeader,\n                  )}\n                >\n                  <span\n                    className={cn(\"text-foreground font-semibold\", isToday(day) && \"text-primary\")}\n                  >\n                    {weekday}\n                  </span>\n                  <span className=\"text-muted-foreground font-medium tabular-nums\">{dayDate}</span>\n                </div>\n                {items.map((segment) => (\n                  <EventCalendarAgendaItem key={segment.occurrence.key} segment={segment} />\n                ))}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </>\n  );\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-agenda-view\",\n    \"data-view\": \"agenda\",\n    // Unlike the grid views the agenda has no row/column semantics to carry a\n    // name, so label the region with the day range it covers - through\n    // formatDayRange, so a consumer override reaches it.\n    role: \"group\",\n    \"aria-label\": settings.i18n.functions.formatDayRange(visibleRange, {\n      locale: settings.locale,\n    }),\n    className: cn(\n      \"flex min-h-0 flex-1 flex-col overflow-hidden border-t\",\n      viewConfig.classNames?.agendaView,\n      className,\n    ),\n    children: native ? (\n      <div\n        data-slot=\"scroll-area-viewport\"\n        data-ec-native-scroll=\"\"\n        className=\"h-full overflow-y-auto\"\n      >\n        {body}\n      </div>\n    ) : (\n      <ScrollArea data-slot=\"scroll-area-viewport\" className=\"h-full\">\n        {body}\n      </ScrollArea>\n    ),\n  };\n\n  return (\n    <EventCalendarViewContext.Provider value={{ view: \"agenda\" }}>\n      <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />\n    </EventCalendarViewContext.Provider>\n  );\n}\n\n/**\n * One agenda row: a full-width, selectable table row - time column, color dot,\n * and title (all replaceable via renderAgendaEvent). Clicking selects the\n * event (drag/resize stay off in the agenda).\n */\nfunction EventCalendarAgendaItem({ segment }: { segment: EventCalendarSegment }) {\n  const viewConfig = useEventCalendarViewConfig();\n  return (\n    <EventCalendarEvent\n      segment={segment}\n      className={cn(\n        // read-only list: hover only, no selected/focused styling on click\n        \"hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors\",\n        viewConfig.classNames?.agendaItem,\n      )}\n    />\n  );\n}\n\nexport type { EventCalendarAgendaViewProps };\nexport { EventCalendarAgendaView };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-agenda-view.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-content.tsx",
      "content": "// Title: Event Calendar Content\n// Description: Active-view switchboard rendering month, week, day, N-days, or agenda; swappable per view via the components prop.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport type * as React from \"react\";\nimport type { ComponentType, ReactNode } from \"react\";\nimport { useEventCalendarSelector, useEventCalendarViewConfig } from \"./event-calendar\";\nimport { EventCalendarAgendaView } from \"./event-calendar-agenda-view\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport { EventCalendarMonthView } from \"./event-calendar-month-view\";\nimport { EventCalendarResourceView } from \"./event-calendar-resource-view\";\nimport {\n  EventCalendarDaysView,\n  EventCalendarDayView,\n  EventCalendarWeekView,\n} from \"./event-calendar-time-grid\";\nimport type { CalendarView } from \"./event-calendar-types\";\n\nconst DEFAULT_VIEW_COMPONENTS: Record<CalendarView, ComponentType> = {\n  month: EventCalendarMonthView,\n  week: EventCalendarWeekView,\n  day: EventCalendarDayView,\n  days: EventCalendarDaysView,\n  agenda: EventCalendarAgendaView,\n  resource: EventCalendarResourceView,\n};\n\ninterface EventCalendarContentProps extends Omit<React.ComponentProps<\"div\">, \"children\"> {\n  /** Swap individual view implementations. */\n  components?: Partial<Record<CalendarView, ComponentType>>;\n  /** Replaces the switchboard entirely; read useEventCalendarView() inside. */\n  children?: ReactNode;\n}\n\nfunction EventCalendarContent({\n  className,\n  components,\n  children,\n  ...props\n}: EventCalendarContentProps) {\n  const viewConfig = useEventCalendarViewConfig();\n  const view = useEventCalendarSelector((state) => state.view);\n  const loading = useEventCalendarSelector((state) => state.loading);\n\n  const resolved = {\n    ...DEFAULT_VIEW_COMPONENTS,\n    ...viewConfig.components,\n    ...components,\n  };\n  // A spread copies keys that hold `undefined`, so `components={{ month: isPro\n  // ? ProMonth : undefined }}` would erase the default and render <undefined />.\n  const ActiveView = resolved[view] ?? DEFAULT_VIEW_COMPONENTS[view];\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-content\",\n    \"data-view\": view,\n    \"data-loading\": loading || undefined,\n    className: cn(\n      \"relative flex min-h-0 min-w-0 flex-1 flex-col\",\n      \"data-loading:pointer-events-none data-loading:opacity-60\",\n      viewConfig.classNames?.content,\n      className,\n    ),\n    children: children ?? <ActiveView />,\n  };\n\n  return <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />;\n}\n\nexport type { EventCalendarContentProps };\nexport { DEFAULT_VIEW_COMPONENTS, EventCalendarContent };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-content.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-dnd.tsx",
      "content": "// Title: Event Calendar Dnd\n// Description: Custom pointer-event interaction engine - move, resize, and drag-create across month, week, day, and N-day views with live validation.\n\n\"use client\";\n\nimport { useCallback, useEffect, useMemo } from \"react\";\nimport { addDays, addMinutes, differenceInCalendarDays } from \"@/lib/date-fns-compat/index\";\nimport {\n  type EventCalendarInstance,\n  useEventCalendar,\n  useEventCalendarViewConfig,\n} from \"./event-calendar\";\nimport { snapMinutes, toZoned, zonedStartOfDay } from \"./event-calendar-lib\";\nimport type { EventCalendarProposedUpdate, EventCalendarSegment } from \"./event-calendar-types\";\n\n/**\n * Activation policy (dnd-kit parity where proven):\n * mouse move 5px before a drag starts (below = click), create 4px;\n * touch long-press 250ms with 5px tolerance (movement past tolerance\n * before the delay cancels the drag so taps stay taps).\n */\nconst EVENT_CALENDAR_ACTIVATION = {\n  moveDistancePx: 5,\n  createDistancePx: 4,\n  touchDelayMs: 250,\n  touchTolerancePx: 5,\n  autoScrollEdgePx: 48,\n  autoScrollMaxStepPx: 15,\n} as const;\n\ntype GestureKind = \"move\" | \"resize-start\" | \"resize-end\" | \"create\";\n\ninterface TimeColumnRect {\n  day: Date;\n  rect: DOMRect;\n  boundsStartMin: number;\n  boundsEndMin: number;\n  resourceId?: string;\n}\n\ninterface DayCellRect {\n  day: Date;\n  rect: DOMRect;\n}\n\ninterface Surface {\n  /** Minute-precise day columns (week/day/days) or resource columns. */\n  columns: TimeColumnRect[];\n  /** Day-precise cells (month grid, all-day row). */\n  cells: DayCellRect[];\n  viewport: HTMLElement | null;\n  /**\n   * Viewport rect captured ONCE at gesture start. The scroll container does\n   * not move on screen while a pointer drag is captured (auto-scroll changes\n   * its scrollTop, not its box), so reusing this avoids a per-pointermove\n   * getBoundingClientRect - that read forces a synchronous full-document\n   * reflow, which is cheap on a bare demo page but ~200ms on a long docs page\n   * (big prop tables re-lay-out on every flush), turning drag into a slideshow.\n   */\n  viewportRect: DOMRect | null;\n  viewportStartScrollTop: number;\n  /**\n   * Live scrollTop, seeded from the start value and advanced by auto-scroll\n   * itself. Tracking it here lets pointerMinutes read a number instead of the\n   * DOM `scrollTop` property every move (another forced reflow).\n   */\n  scrollTop: number;\n}\n\n/** Module flag so chip onClick can ignore the click that ends a drag. */\nlet lastGestureEndedAt = 0;\nfunction wasRecentDrag(): boolean {\n  return performance.now() - lastGestureEndedAt < 250;\n}\n\n/**\n * A press that started on an event chip. Slot-create clicks consult this so a\n * refused drag (e.g. a locked chip that never registers a gesture) whose\n * trailing native click retargets to the empty grid does NOT open a create\n * dialog. Refreshed on release so it covers long presses; the chip's own\n * click-to-edit is unaffected (only grid slot-clicks check it).\n */\nlet lastChipPressAt = 0;\nfunction markChipPress(): void {\n  lastChipPressAt = performance.now();\n  window.addEventListener(\n    \"pointerup\",\n    () => {\n      lastChipPressAt = performance.now();\n    },\n    { once: true, capture: true },\n  );\n}\nfunction wasRecentChipPress(): boolean {\n  return performance.now() - lastChipPressAt < 300;\n}\n\n/**\n * Registry of in-flight gesture cancels. A gesture measures its surface (day\n * columns and cells) once at activation and then lives on window listeners, so\n * the CALENDAR - not the chip, chips legitimately unmount mid-gesture (lane\n * repacking, a \"+N more\" popover closing) - is what must be able to abort it.\n * Cancel fully reverts: listeners, overlays and the body drag state all clear,\n * and no update is committed.\n */\nconst activeGestureCancels = new Set<() => void>();\n\n/** Cancel (and fully revert) every in-flight event calendar pointer gesture. */\nfunction cancelActiveEventCalendarGestures(): void {\n  for (const cancel of [...activeGestureCancels]) cancel();\n}\n\n/**\n * Mounted-consumer count for the gestures hook. Every chip holds one, so only\n * the LAST consumer leaving means the calendar itself is gone; aborting when\n * any single chip unmounts would kill a drag the user is still holding.\n */\nlet gestureConsumers = 0;\n\n/**\n * Snap a translate offset to the device pixel grid. The cursor-following\n * overlays (carry clone, drop hint) are their own `will-change: transform`\n * compositing layers: the GPU rasterizes their text once and repositions that\n * texture each frame, so a subpixel translate (getBoundingClientRect and raw\n * clientX/Y are routinely fractional) resamples the texture and blurs the\n * text. Rounding each offset to a whole device pixel lands the layer on the\n * grid so glyphs stay crisp, without giving up the per-frame GPU transform.\n */\nfunction snapToPixel(value: number): number {\n  const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n  return Math.round(value * dpr) / dpr;\n}\n\nfunction collectSurface(origin: HTMLElement, fallbackRoot?: HTMLElement | null): Surface {\n  // A gesture from a portaled surface (the \"+N more\" popover) has no calendar\n  // ancestor, so fall back to the root the host registered on the instance.\n  const root =\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar-time-grid]\") ??\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar-resource-view]\") ??\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar-month-view]\") ??\n    origin.closest<HTMLElement>(\"[data-slot=event-calendar]\") ??\n    fallbackRoot ??\n    null;\n\n  const columns: TimeColumnRect[] = [];\n  const cells: DayCellRect[] = [];\n  if (root) {\n    for (const el of root.querySelectorAll<HTMLElement>(\"[data-ec-day]\")) {\n      const day = new Date(Number(el.dataset.ecDay));\n      if (el.dataset.ecBoundsStart !== undefined) {\n        columns.push({\n          day,\n          rect: el.getBoundingClientRect(),\n          boundsStartMin: Number(el.dataset.ecBoundsStart),\n          boundsEndMin: Number(el.dataset.ecBoundsEnd),\n          resourceId: el.dataset.ecResource,\n        });\n      } else {\n        cells.push({ day, rect: el.getBoundingClientRect() });\n      }\n    }\n  }\n  const viewport = root?.querySelector<HTMLElement>(\"[data-slot=scroll-area-viewport]\") ?? null;\n  const viewportStartScrollTop = viewport?.scrollTop ?? 0;\n  return {\n    columns,\n    cells,\n    viewport,\n    viewportRect: viewport?.getBoundingClientRect() ?? null,\n    viewportStartScrollTop,\n    scrollTop: viewportStartScrollTop,\n  };\n}\n\nfunction findColumn(surface: Surface, clientX: number): TimeColumnRect | undefined {\n  const scrollAdjusted = surface.columns;\n  let best: TimeColumnRect | undefined;\n  for (const col of scrollAdjusted) {\n    if (clientX >= col.rect.left && clientX < col.rect.right) return col;\n    if (!best) best = col;\n    // clamp to nearest horizontal column\n    const bestDist = Math.min(\n      Math.abs(clientX - best.rect.left),\n      Math.abs(clientX - best.rect.right),\n    );\n    const dist = Math.min(Math.abs(clientX - col.rect.left), Math.abs(clientX - col.rect.right));\n    if (dist < bestDist) best = col;\n  }\n  return best;\n}\n\nfunction findCell(surface: Surface, x: number, y: number): DayCellRect | undefined {\n  return surface.cells.find(\n    (cell) =>\n      x >= cell.rect.left && x < cell.rect.right && y >= cell.rect.top && y < cell.rect.bottom,\n  );\n}\n\nfunction pointerMinutes(surface: Surface, col: TimeColumnRect, clientY: number): number {\n  const scrollDelta = surface.scrollTop - surface.viewportStartScrollTop;\n  const boundsMinutes = col.boundsEndMin - col.boundsStartMin;\n  const pxPerMinute = col.rect.height / Math.max(1, boundsMinutes);\n  const y = clientY - col.rect.top + scrollDelta;\n  return col.boundsStartMin + y / pxPerMinute;\n}\n\ninterface BeginGestureConfig<TData> {\n  instance: EventCalendarInstance<TData>;\n  kind: GestureKind;\n  origin: HTMLElement;\n  startEvent: PointerEvent;\n  segment?: EventCalendarSegment<TData>;\n  /** create only */\n  createDay?: Date;\n  createAllDay?: boolean;\n  /**\n   * Consumer classNames for the engine's vanilla-DOM overlays (carry clone,\n   * validation hint pill), forwarded by the gestures hook - the engine itself\n   * must never import from the React chip module.\n   */\n  ui?: {\n    dragCarry?: string;\n    dragCarryInvalid?: string;\n    dropHint?: string;\n  };\n}\n\n/**\n * A move/resize was refused (locked, per-event disabled, or interaction off).\n * Rather than silently swallow the press, track the pointer: once it crosses\n * the activation threshold - i.e. the user genuinely tried to drag - show a\n * not-allowed cursor and broadcast once via onDragBlocked so the consumer can\n * explain it. The calendar picks no message. Fires at most once per gesture.\n */\nfunction beginBlockedGesture<TData>(\n  instance: EventCalendarInstance<TData>,\n  startEvent: PointerEvent,\n  segment: EventCalendarSegment<TData>,\n  gesture: \"move\" | \"resize\",\n) {\n  const startX = startEvent.clientX;\n  const startY = startEvent.clientY;\n  const activation = {\n    ...EVENT_CALENDAR_ACTIVATION,\n    ...instance.settings.activation,\n  };\n  const event = segment.occurrence.event;\n  const reason: \"readOnly\" | \"disabled\" | \"interactions-off\" = event.readOnly\n    ? \"readOnly\"\n    : (gesture === \"move\" ? event.draggable : event.resizable) === false\n      ? \"disabled\"\n      : \"interactions-off\";\n  const pointerId = startEvent.pointerId;\n  let activated = false;\n  let finished = false;\n  const onMove = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId || activated) return;\n    if (Math.hypot(e.clientX - startX, e.clientY - startY) < activation.moveDistancePx) {\n      return;\n    }\n    activated = true;\n    document.body.style.cursor = \"not-allowed\";\n    // body class alongside the inline cursor so consumer CSS can restyle or\n    // detect the blocked-drag state (mirrors \"ec-dragging\")\n    document.body.classList.add(\"ec-drag-blocked\");\n    // only now is there anything to get stuck: focus loss means the release\n    // may never be delivered, leaving the cursor not-allowed document-wide\n    window.addEventListener(\"blur\", cleanup);\n    instance.settings.onDragBlocked?.(segment.occurrence, { gesture, reason });\n  };\n  const cleanup = () => {\n    if (finished) return;\n    finished = true;\n    window.removeEventListener(\"pointermove\", onMove);\n    window.removeEventListener(\"pointerup\", onRelease);\n    window.removeEventListener(\"pointercancel\", onRelease);\n    window.removeEventListener(\"blur\", cleanup);\n    if (activated) {\n      document.body.style.cursor = \"\";\n      document.body.classList.remove(\"ec-drag-blocked\");\n      // the pointer travelled: suppress the trailing click so the event's own\n      // dialog does not open on top of the rejection message\n      lastGestureEndedAt = performance.now();\n    }\n  };\n  // only the pointer that started the press may end it: a second finger\n  // lifting must not clear the not-allowed cursor out from under it\n  const onRelease = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId) return;\n    cleanup();\n  };\n  window.addEventListener(\"pointermove\", onMove);\n  window.addEventListener(\"pointerup\", onRelease);\n  window.addEventListener(\"pointercancel\", onRelease);\n}\n\nfunction beginGesture<TData>(config: BeginGestureConfig<TData>) {\n  const { instance, kind, origin, startEvent, segment, ui } = config;\n  const { settings, internals, api } = instance;\n  const timeZone = settings.timeZone;\n  const snap = settings.snapDuration;\n  // per-calendar tuning shallow-merged over the module defaults\n  const activation = { ...EVENT_CALENDAR_ACTIVATION, ...settings.activation };\n  const startX = startEvent.clientX;\n  const startY = startEvent.clientY;\n  const pointerId = startEvent.pointerId;\n  // Resolved once: the chip can be re-rendered away mid-gesture. A gesture\n  // from a portaled surface (the \"+N more\" popover) has no calendar ancestor,\n  // so fall back to the registered root - same reason as collectSurface.\n  const announcer =\n    origin\n      .closest<HTMLElement>(\"[data-slot=event-calendar]\")\n      ?.querySelector<HTMLElement>(\"[data-slot=event-calendar-announcer]\") ??\n    internals.getRootEl()?.querySelector<HTMLElement>(\"[data-slot=event-calendar-announcer]\") ??\n    null;\n\n  const isTouch = startEvent.pointerType === \"touch\";\n  // resize activates immediately on precise pointers; on touch it waits for\n  // the same long-press as a move, so a finger landing on the (invisible on\n  // touch) handle strip can never start a resize the user never asked for\n  let active = kind.startsWith(\"resize\") && !isTouch;\n  let surface: Surface | null = active ? collectSurface(origin, internals.getRootEl()) : null;\n  let rafScroll: number | null = null;\n  let lastProposalKey = \"\";\n  let touchTimer: ReturnType<typeof setTimeout> | null = null;\n  let hintEl: HTMLDivElement | null = null;\n  let lastValid = true;\n  // Drag-create anchor minute, frozen at the first proposal: re-deriving it\n  // from the stale startY client coordinate + the CURRENT scroll delta would\n  // make the anchor drift with auto-scroll instead of staying pinned.\n  let createAnchorMin: number | null = null;\n  let lastPointer: PointerEvent = startEvent;\n\n  const occurrence = segment?.occurrence;\n  const isBar = occurrence\n    ? occurrence.allDay ||\n      occurrence.end.getTime() - occurrence.start.getTime() > 24 * 60 * 60 * 1000\n    : false;\n\n  // Preserve the grab offset so the event does not jump to the pointer\n  let grabOffsetMin = 0;\n  /**\n   * The grabbed CHIP, not the whole occurrence. A cross-midnight event renders\n   * one chip per day, so the offset above is measured in the grabbed chip's own\n   * day frame (mixing the two frames jumps a tail chip a full day on grab) and\n   * these two carry that chip back to the occurrence. For a chip that fits in\n   * one day the lead is 0 and the duration is the occurrence's, i.e. unchanged.\n   */\n  let grabLeadMs = 0;\n  let grabSegDurationMs = occurrence ? occurrence.end.getTime() - occurrence.start.getTime() : 0;\n  /**\n   * Which day of a multi-day bar was grabbed. Without it the day-granular move\n   * slides the bar's START under the pointer, so a Mon-Fri bar grabbed on\n   * Wednesday teleports two days forward on the first nudge.\n   */\n  let grabDayOffset = 0;\n\n  const activationDistance =\n    kind === \"create\" ? activation.createDistancePx : activation.moveDistancePx;\n\n  // Each gesture keeps its own cursor: a resize must stay ns/ew-resize for\n  // the whole drag (flipping to grabbing reads as a move) - vertical for\n  // timed blocks, horizontal for day-granular bars. Moves grab.\n  const gestureCursor = kind.startsWith(\"resize\")\n    ? isBar\n      ? \"ew-resize\"\n      : \"ns-resize\"\n    : \"grabbing\";\n  const setBodyDragging = (on: boolean, invalid = false) => {\n    document.body.classList.toggle(\"ec-dragging\", on);\n    document.body.style.cursor = on ? (invalid ? \"not-allowed\" : gestureCursor) : \"\";\n    document.body.style.userSelect = on ? \"none\" : \"\";\n    if (!on) document.body.style.removeProperty(\"-webkit-user-select\");\n  };\n\n  const activate = () => {\n    if (active) return;\n    active = true;\n    // armed with the gesture, not with the press: a press that never activates\n    // holds no drag state, no overlay and no body class to strand\n    window.addEventListener(\"blur\", onWindowBlur);\n    surface = collectSurface(origin, internals.getRootEl());\n    if (kind === \"move\" && occurrence) {\n      const grabCell = findCell(surface, startX, startY);\n      if (grabCell) {\n        const originDay = zonedStartOfDay(occurrence.start, timeZone);\n        // end is exclusive, so step back an instant for the last covered day\n        const lastDay = zonedStartOfDay(\n          new Date(Math.max(occurrence.end.getTime() - 1, occurrence.start.getTime())),\n          timeZone,\n        );\n        const offset = differenceInCalendarDays(zonedStartOfDay(grabCell.day, timeZone), originDay);\n        // A gesture from the \"+N more\" popover grabs over whatever cell that\n        // floating surface happens to cover, so only a day the event actually\n        // spans can be the grabbed day.\n        if (offset >= 0 && offset <= differenceInCalendarDays(lastDay, originDay)) {\n          grabDayOffset = offset;\n        }\n      }\n      if (surface.columns.length > 0 && !isBar) {\n        const col = findColumn(surface, startX);\n        if (col) {\n          const colDayStart = zonedStartOfDay(col.day, timeZone);\n          const segStartMs = Math.max(occurrence.start.getTime(), colDayStart.getTime());\n          grabLeadMs = segStartMs - occurrence.start.getTime();\n          grabSegDurationMs =\n            Math.min(occurrence.end.getTime(), addDays(colDayStart, 1).getTime()) - segStartMs;\n          grabOffsetMin =\n            pointerMinutes(surface, col, startY) - (segStartMs - colDayStart.getTime()) / 60000;\n        }\n      }\n    }\n    setBodyDragging(true);\n    createCarry();\n  };\n\n  const computeProposal = (\n    e: PointerEvent,\n  ): {\n    start: Date;\n    end: Date;\n    allDay: boolean;\n    dayGranular?: boolean;\n    resourceId?: string;\n  } | null => {\n    if (!surface) return null;\n\n    // ---- create: select a slot range\n    if (kind === \"create\") {\n      if (config.createAllDay || surface.columns.length === 0) {\n        if (!config.createDay) return null;\n        const anchor = zonedStartOfDay(config.createDay, timeZone);\n        const cell = findCell(surface, e.clientX, e.clientY);\n        const target = cell ? zonedStartOfDay(cell.day, timeZone) : anchor;\n        const start = anchor <= target ? anchor : target;\n        const end = addDays(anchor <= target ? target : anchor, 1);\n        return { start, end, allDay: true, dayGranular: true };\n      }\n      const col = findColumn(surface, startX);\n      if (!col) return null;\n      if (createAnchorMin === null) {\n        createAnchorMin = snapMinutes(pointerMinutes(surface, col, startY), snap);\n      }\n      const anchorMin = createAnchorMin;\n      const curMin = snapMinutes(pointerMinutes(surface, col, e.clientY), snap);\n      const lo = Math.max(col.boundsStartMin, Math.min(anchorMin, curMin));\n      const hi = Math.min(col.boundsEndMin, Math.max(anchorMin, curMin, lo + snap));\n      const dayStart = zonedStartOfDay(col.day, timeZone);\n      return {\n        start: addMinutes(dayStart, lo),\n        end: addMinutes(dayStart, hi),\n        allDay: false,\n        resourceId: col.resourceId,\n      };\n    }\n\n    if (!occurrence) return null;\n    const durationMs = occurrence.end.getTime() - occurrence.start.getTime();\n\n    // ---- day-granularity: month cells and bars in the all-day row\n    const overCell = findCell(surface, e.clientX, e.clientY);\n\n    // A timed event dropped on the all-day lane (a day cell inside a surface\n    // that also has time columns) converts to a full-day event on that day.\n    if (kind === \"move\" && !isBar && surface.columns.length > 0 && overCell !== undefined) {\n      const targetDay = zonedStartOfDay(overCell.day, timeZone);\n      return {\n        start: targetDay,\n        end: addDays(targetDay, 1),\n        allDay: true,\n        dayGranular: true,\n      };\n    }\n\n    const useCells = surface.columns.length === 0 || (isBar && overCell !== undefined);\n\n    if (useCells) {\n      const cell = overCell ?? findCell(surface, startX, startY);\n      if (!cell) return null;\n      const targetDay = zonedStartOfDay(cell.day, timeZone);\n      if (kind === \"move\") {\n        const originDay = zonedStartOfDay(occurrence.start, timeZone);\n        // minus the grabbed day: the bar follows the pointer by the distance\n        // travelled, it does not re-anchor its start under the pointer\n        const delta = differenceInCalendarDays(targetDay, originDay) - grabDayOffset;\n        const start = addDays(toZoned(occurrence.start, timeZone), delta);\n        return {\n          start,\n          end: new Date(start.getTime() + durationMs),\n          allDay: occurrence.allDay,\n          dayGranular: true,\n        };\n      }\n      // bar edge resize: day granularity\n      if (kind === \"resize-start\") {\n        const time =\n          occurrence.start.getTime() - zonedStartOfDay(occurrence.start, timeZone).getTime();\n        const start = new Date(targetDay.getTime() + time);\n        if (start >= occurrence.end) return null;\n        return {\n          start,\n          end: occurrence.end,\n          allDay: occurrence.allDay,\n          dayGranular: true,\n        };\n      }\n      const time = occurrence.allDay\n        ? 0\n        : occurrence.end.getTime() - zonedStartOfDay(occurrence.end, timeZone).getTime();\n      const end = occurrence.allDay\n        ? addDays(targetDay, 1)\n        : new Date(addDays(targetDay, time > 0 ? 0 : 1).getTime() + time);\n      if (end <= occurrence.start) return null;\n      return {\n        start: occurrence.start,\n        end,\n        allDay: occurrence.allDay,\n        dayGranular: true,\n      };\n    }\n\n    // ---- minute-granularity: time-grid columns\n    const col = findColumn(surface, e.clientX);\n    if (!col) return null;\n    const dayStart = zonedStartOfDay(col.day, timeZone);\n    const rawMin = pointerMinutes(surface, col, e.clientY);\n\n    if (kind === \"move\") {\n      const newStartMin = snapMinutes(rawMin - grabOffsetMin, snap);\n      // the clamp keeps the grabbed CHIP inside the column it is over; the\n      // lead then carries the rest of a cross-midnight occurrence with it\n      const chipDurationMin = Math.round(grabSegDurationMs / 60000);\n      const clamped = Math.min(\n        Math.max(newStartMin, col.boundsStartMin),\n        col.boundsEndMin - chipDurationMin,\n      );\n      const start = new Date(addMinutes(dayStart, clamped).getTime() - grabLeadMs);\n      return {\n        start,\n        end: new Date(start.getTime() + durationMs),\n        allDay: false,\n        resourceId: col.resourceId,\n      };\n    }\n\n    const min = snapMinutes(rawMin, snap);\n    // Both endpoints expressed in the POINTED column's day coordinates.\n    // Anchoring each endpoint to its OWN day breaks cross-midnight events:\n    // a 23:30 pointer in the start day's column would apply 1410 minutes to\n    // the end's next-day anchor and jump the end a full day late (and a\n    // midnight-ending event computes occEndMin 0 against its own day).\n    const occStartMinInCol = Math.round((occurrence.start.getTime() - dayStart.getTime()) / 60000);\n    const occEndMinInCol = Math.round((occurrence.end.getTime() - dayStart.getTime()) / 60000);\n    if (kind === \"resize-start\") {\n      const clamped = Math.min(\n        Math.max(min, col.boundsStartMin),\n        Math.min(occEndMinInCol - snap, col.boundsEndMin),\n      );\n      const start = addMinutes(dayStart, clamped);\n      if (start >= occurrence.end) return null;\n      return { start, end: occurrence.end, allDay: false };\n    }\n    const clamped = Math.max(\n      Math.min(min, col.boundsEndMin),\n      Math.max(occStartMinInCol + snap, col.boundsStartMin),\n    );\n    const end = addMinutes(dayStart, clamped);\n    if (end <= occurrence.start) return null;\n    return { start: occurrence.start, end, allDay: false };\n  };\n\n  const applyProposal = (e: PointerEvent) => {\n    const proposal = computeProposal(e);\n    if (!proposal) return;\n    const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? \"\"}`;\n    if (key === lastProposalKey) return;\n    lastProposalKey = key;\n\n    if (kind === \"create\") {\n      const draft = { ...proposal, view: instance.getState().view };\n      if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return;\n      internals.setSlotDraft(draft);\n      return;\n    }\n    // Every gesture but \"create\" starts from an occurrence.\n    if (!occurrence) return;\n    const update: EventCalendarProposedUpdate<TData> = {\n      event: occurrence.event,\n      occurrence,\n      ...proposal,\n      source: kind as \"drag\" | \"resize-start\" | \"resize-end\",\n    };\n    if (kind === \"move\") update.source = \"drag\";\n    const valid = settings.canDropEvent ? settings.canDropEvent(update) : true;\n    lastValid = valid;\n    setBodyDragging(true, !valid);\n    internals.setDrag({\n      kind: kind === \"move\" ? \"move\" : (kind as \"resize-start\" | \"resize-end\"),\n      occurrence,\n      proposedStart: proposal.start,\n      proposedEnd: proposal.end,\n      proposedAllDay: proposal.allDay,\n      proposedDayGranular: proposal.dayGranular ?? false,\n      proposedResourceId: proposal.resourceId,\n      valid,\n    });\n  };\n\n  // Cursor-attached carry clone for MOVE gestures: the event travels freely\n  // with the pointer, like an absolutely positioned overlay, while the\n  // in-grid ghost renders only a faint dashed placeholder at the snapped\n  // drop slot (EVENT_CALENDAR_GHOST.move). Vanilla DOM: cloned once at\n  // activation, transformed per pointermove, zero React work per frame.\n  let carryEl: HTMLDivElement | null = null;\n  let carryDX = 0;\n  let carryDY = 0;\n  const CARRY_CLASS =\n    \"bg-background pointer-events-none fixed top-0 left-0 z-100 overflow-hidden rounded-sm opacity-90 shadow-md will-change-transform\" +\n    (ui?.dragCarry ? ` ${ui.dragCarry}` : \"\");\n  const CARRY_INVALID_CLASS =\n    `${CARRY_CLASS} ring-destructive/60 ring-1` +\n    (ui?.dragCarryInvalid ? ` ${ui.dragCarryInvalid}` : \"\");\n\n  const createCarry = () => {\n    if (kind !== \"move\") return;\n    const chip = origin.closest<HTMLElement>(\"[data-slot=event-calendar-event]\") ?? origin;\n    const rect = chip.getBoundingClientRect();\n    // The clone is re-parented to <body>, escaping the calendar's inherited\n    // font-size, so copy the source chip's resolved type - this keeps the carry\n    // matching the grid at any consumer text scale (e.g. root text-sm).\n    const chipFont = getComputedStyle(chip);\n    carryDX = startX - rect.left;\n    carryDY = startY - rect.top;\n    carryEl = document.createElement(\"div\");\n    carryEl.setAttribute(\"data-slot\", \"event-calendar-drag-carry\");\n    carryEl.setAttribute(\"aria-hidden\", \"true\");\n    carryEl.className = CARRY_CLASS;\n    carryEl.style.width = `${rect.width}px`;\n    carryEl.style.height = `${rect.height}px`;\n    carryEl.style.fontSize = chipFont.fontSize;\n    carryEl.style.lineHeight = chipFont.lineHeight;\n    carryEl.style.transform = `translate3d(${snapToPixel(rect.left)}px, ${snapToPixel(rect.top)}px, 0)`;\n    const clone = chip.cloneNode(true) as HTMLElement;\n    clone.removeAttribute(\"data-dragging\");\n    clone.style.width = \"100%\";\n    clone.style.height = \"100%\";\n    carryEl.appendChild(clone);\n    document.body.appendChild(carryEl);\n  };\n\n  const positionCarry = (e: PointerEvent) => {\n    if (!carryEl) return;\n    carryEl.style.transform = `translate3d(${snapToPixel(e.clientX - carryDX)}px, ${snapToPixel(e.clientY - carryDY)}px, 0)`;\n    const cls = lastValid ? CARRY_CLASS : CARRY_INVALID_CLASS;\n    if (carryEl.className !== cls) carryEl.className = cls;\n  };\n\n  // Cursor-following validation hint, visible ONLY while the proposal is\n  // rejected (canDropEvent / bounds). Vanilla DOM: zero React work per frame;\n  // pairs with the not-allowed cursor and the ghost's destructive marking.\n  const updateHint = (e: PointerEvent) => {\n    if (lastValid || !active) {\n      hintEl?.remove();\n      hintEl = null;\n      return;\n    }\n    if (!hintEl) {\n      hintEl = document.createElement(\"div\");\n      hintEl.setAttribute(\"data-slot\", \"event-calendar-drop-hint\");\n      // physical left-0 anchor: translate3d positions in physical clientX\n      // coordinates, so a logical start-0 anchor would fling it off-screen\n      // in RTL documents\n      hintEl.className =\n        \"bg-background text-destructive border-destructive/40 pointer-events-none fixed top-0 left-0 z-100 rounded-md border px-2 py-0.5 text-xs font-medium shadow-sm\" +\n        (ui?.dropHint ? ` ${ui.dropHint}` : \"\");\n      hintEl.textContent = settings.i18n.labels.dropNotAllowed;\n      document.body.appendChild(hintEl);\n    }\n    hintEl.style.transform = `translate3d(${snapToPixel(e.clientX + 12)}px, ${snapToPixel(e.clientY + 16)}px, 0)`;\n  };\n\n  const autoScroll = (e: PointerEvent) => {\n    // Cached rect (see Surface.viewportRect) - never getBoundingClientRect per\n    // move; the box is stable while the pointer is captured.\n    const rect = surface?.viewportRect;\n    if (!surface?.viewport || !rect) return;\n    const edge = activation.autoScrollEdgePx;\n    const step = activation.autoScrollMaxStepPx;\n    let delta = 0;\n    /**\n     * The scroller wraps the time track ONLY - the day headers and the all-day\n     * row sit above it, outside the box - so its top edge is a hard floor. A\n     * pointer above that floor is over one of those rows, and both are drop\n     * targets in their own right (a bar moving across dates, a timed chip\n     * lifted onto the all-day lane to convert it); they are already fully\n     * visible, so reaching them is never a request to scroll. Without the\n     * floor, \"past the top edge\" is true on every single move of a horizontal\n     * all-day drag and the track pans out from under a gesture that only wants\n     * to change the date - fast, because the proximity ratio grows past 1 the\n     * further above the box the pointer sits.\n     *\n     * Below the track there is no such row, so overshooting the bottom keeps\n     * scrolling - that is how a grid is normally asked to keep going - but the\n     * eased speed is capped at one step per frame for the same reason.\n     */\n    if (e.clientY >= rect.top) {\n      if (e.clientY < rect.top + edge) {\n        delta = -step * ((rect.top + edge - e.clientY) / edge);\n      } else if (e.clientY > rect.bottom - edge) {\n        delta = step * Math.min(1, (e.clientY - (rect.bottom - edge)) / edge);\n      }\n    }\n    if (rafScroll) cancelAnimationFrame(rafScroll);\n    if (delta !== 0) {\n      const tick = () => {\n        // Write-then-readback: the browser clamps scrollTop at the scroll\n        // extent, so mirror only the APPLIED delta - otherwise parking the\n        // pointer in the edge zone at the limit keeps inflating the tracked\n        // value past reality and poisons every later minute mapping. When\n        // parked (nothing applied), stop the loop; the next pointermove\n        // restarts it.\n        const viewport = surface?.viewport;\n        if (!surface || !viewport) return;\n        const before = viewport.scrollTop;\n        viewport.scrollTop = before + delta;\n        const applied = viewport.scrollTop - before;\n        if (applied === 0) return;\n        // keep the tracked scrollTop in step so pointerMinutes stays a pure\n        // number read (no DOM scrollTop, no forced reflow)\n        surface.scrollTop += applied;\n        applyProposal(e);\n        rafScroll = requestAnimationFrame(tick);\n      };\n      rafScroll = requestAnimationFrame(tick);\n    }\n  };\n\n  // idempotent: pointerup, pointercancel, Escape, blur and the calendar-level\n  // teardown can race; whichever lands first wins and the rest no-op\n  let finished = false;\n  const cleanup = () => {\n    if (finished) return;\n    finished = true;\n    activeGestureCancels.delete(cancel);\n    window.removeEventListener(\"pointermove\", onPointerMove);\n    window.removeEventListener(\"pointerup\", onPointerUp);\n    window.removeEventListener(\"pointercancel\", onCancel);\n    window.removeEventListener(\"blur\", onWindowBlur);\n    window.removeEventListener(\"keydown\", onKeyDown, true);\n    if (rafScroll) cancelAnimationFrame(rafScroll);\n    if (touchTimer) clearTimeout(touchTimer);\n    hintEl?.remove();\n    hintEl = null;\n    carryEl?.remove();\n    carryEl = null;\n    setBodyDragging(false);\n  };\n\n  const cancel = () => {\n    cleanup();\n    if (active) {\n      lastGestureEndedAt = performance.now();\n      internals.setDrag(null);\n      internals.setSlotDraft(null);\n    }\n  };\n\n  const onKeyDown = (e: KeyboardEvent) => {\n    if (e.key === \"Escape\") {\n      e.stopPropagation();\n      cancel();\n    }\n  };\n\n  // focus loss mid-gesture (alt-tab, OS dialogs) means the release may never\n  // be delivered; treat it as a cancel so the gesture cannot get stuck and\n  // commit its stale proposal on the next click\n  const onWindowBlur = () => cancel();\n\n  const onPointerMove = (e: PointerEvent) => {\n    // a second finger must not drive - or cancel the pending long press of -\n    // the gesture this pointer started\n    if (e.pointerId !== pointerId) return;\n    lastPointer = e;\n    if (!active) {\n      const distance = Math.hypot(e.clientX - startX, e.clientY - startY);\n      if (isTouch) {\n        // Long-press pending: moving past tolerance means scroll, not drag\n        if (distance > activation.touchTolerancePx) cancel();\n        return;\n      }\n      if (distance < activationDistance) return;\n      activate();\n    }\n    applyProposal(e);\n    autoScroll(e);\n    updateHint(e);\n    positionCarry(e);\n  };\n\n  const onPointerUp = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId) return;\n    cleanup();\n    if (!active) return;\n    lastGestureEndedAt = performance.now();\n\n    const state = instance.getState();\n    if (kind === \"create\") {\n      const draft = state.slotDraft;\n      internals.setSlotDraft(null);\n      if (draft) {\n        api.select({\n          slot: { start: draft.start, end: draft.end, allDay: draft.allDay },\n        });\n        settings.onSelectSlot?.(draft);\n      }\n      return;\n    }\n    const drag = state.drag;\n    internals.setDrag(null);\n    if (!drag || !occurrence) return;\n    const unchanged =\n      drag.proposedStart.getTime() === occurrence.start.getTime() &&\n      drag.proposedEnd.getTime() === occurrence.end.getTime() &&\n      (drag.proposedResourceId === undefined ||\n        drag.proposedResourceId === occurrence.event.resourceId);\n    if (unchanged) return;\n    // Commit through the one validation funnel; consumer reject = automatic\n    // revert because the calendar never mutated during the gesture.\n    const accepted = internals.applyProposedUpdate({\n      event: occurrence.event,\n      occurrence,\n      start: drag.proposedStart,\n      end: drag.proposedEnd,\n      allDay: drag.proposedAllDay,\n      resourceId: drag.proposedResourceId,\n      source: kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n    });\n    // the polite live region is the only feedback a screen-reader user gets\n    // that the drop landed, and on what; a vetoed commit stays silent\n    if (accepted && announcer) {\n      announcer.textContent = `${occurrence.event.title}, ${settings.i18n.functions.formatEventTime(\n        toZoned(drag.proposedStart, timeZone),\n        toZoned(drag.proposedEnd, timeZone),\n        drag.proposedAllDay,\n        { locale: settings.locale },\n      )}`;\n    }\n  };\n\n  const onCancel = (e: PointerEvent) => {\n    if (e.pointerId !== pointerId) return;\n    cancel();\n  };\n\n  window.addEventListener(\"pointermove\", onPointerMove);\n  window.addEventListener(\"pointerup\", onPointerUp);\n  window.addEventListener(\"pointercancel\", onCancel);\n  // a resize on a precise pointer is live from the first frame, so it never\n  // reaches activate() to arm its own blur cancel\n  if (active) window.addEventListener(\"blur\", onWindowBlur);\n  window.addEventListener(\"keydown\", onKeyDown, true);\n  activeGestureCancels.add(cancel);\n\n  // Touch: long-press activation (movement past tolerance cancels above)\n  if (isTouch && !active) {\n    touchTimer = setTimeout(() => {\n      activate();\n      applyProposal(lastPointer);\n    }, activation.touchDelayMs);\n  }\n}\n\n/** Per-chip / per-surface pointer gesture wiring. */\nfunction useEventCalendarGestures<TData = unknown>() {\n  const instance = useEventCalendar<TData>();\n  // Overlay classNames bridged from React config into the vanilla-DOM engine\n  // (which must never import from the chip module - circular).\n  const { classNames } = useEventCalendarViewConfig<TData>();\n  const ui = useMemo(\n    () => ({\n      dragCarry: classNames?.dragCarry,\n      dragCarryInvalid: classNames?.dragCarryInvalid,\n      dropHint: classNames?.dropHint,\n    }),\n    [classNames],\n  );\n\n  // An in-flight gesture lives on window listeners and body-appended overlays,\n  // so it must never outlive the calendar. Chips hold this hook too and they\n  // legitimately unmount mid-gesture, so only the LAST consumer leaving - the\n  // calendar itself going away - aborts.\n  useEffect(() => {\n    gestureConsumers += 1;\n    return () => {\n      gestureConsumers -= 1;\n      if (gestureConsumers === 0) cancelActiveEventCalendarGestures();\n    };\n  }, []);\n\n  const canDrag = useCallback(\n    (segment: EventCalendarSegment<TData>) => {\n      const { interactions } = instance.getState();\n      const event = segment.occurrence.event;\n      return interactions.drag && !event.readOnly && event.draggable !== false;\n    },\n    [instance],\n  );\n\n  const canResize = useCallback(\n    (segment: EventCalendarSegment<TData>) => {\n      const { interactions } = instance.getState();\n      const event = segment.occurrence.event;\n      return interactions.resize && !event.readOnly && event.resizable !== false;\n    },\n    [instance],\n  );\n\n  const beginMove = useCallback(\n    (e: React.PointerEvent, segment: EventCalendarSegment<TData>) => {\n      if (e.button !== 0) return;\n      if (!canDrag(segment)) {\n        // refused, but still give feedback + broadcast on a real drag attempt\n        beginBlockedGesture(instance, e.nativeEvent, segment, \"move\");\n        return;\n      }\n      beginGesture({\n        instance,\n        kind: \"move\",\n        origin: e.currentTarget as HTMLElement,\n        startEvent: e.nativeEvent,\n        segment,\n        ui,\n      });\n    },\n    [instance, canDrag, ui],\n  );\n\n  const beginResize = useCallback(\n    (e: React.PointerEvent, segment: EventCalendarSegment<TData>, edge: \"start\" | \"end\") => {\n      if (e.button !== 0) return;\n      if (!canResize(segment)) {\n        e.stopPropagation();\n        beginBlockedGesture(instance, e.nativeEvent, segment, \"resize\");\n        return;\n      }\n      e.stopPropagation();\n      e.preventDefault();\n      beginGesture({\n        instance,\n        kind: edge === \"start\" ? \"resize-start\" : \"resize-end\",\n        origin: e.currentTarget as HTMLElement,\n        startEvent: e.nativeEvent,\n        segment,\n        ui,\n      });\n    },\n    [instance, canResize, ui],\n  );\n\n  const beginCreate = useCallback(\n    (e: React.PointerEvent, day: Date, allDay: boolean) => {\n      if (e.button !== 0) return;\n      if (!instance.getState().interactions.selectSlot) return;\n      beginGesture({\n        instance,\n        kind: \"create\",\n        origin: e.currentTarget as HTMLElement,\n        startEvent: e.nativeEvent,\n        createDay: day,\n        createAllDay: allDay,\n        ui,\n      });\n    },\n    [instance, ui],\n  );\n\n  return { beginMove, beginResize, beginCreate, canDrag, canResize };\n}\n\nexport {\n  cancelActiveEventCalendarGestures,\n  EVENT_CALENDAR_ACTIVATION,\n  markChipPress,\n  useEventCalendarGestures,\n  wasRecentChipPress,\n  wasRecentDrag,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-dnd.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-event.tsx",
      "content": "// Title: Event Calendar Event\n// Description: The reusable event chip/bar/block - selection, clicks, drag + resize wiring, and the consumer render slot.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport { PlusIcon, RepeatIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { type CSSProperties, createContext, type ReactNode, useContext, useMemo } from \"react\";\nimport { Tooltip, TooltipTrigger } from \"@/core/tooltip/tooltip\";\nimport { addDays, format } from \"@/lib/date-fns-compat/index\";\nimport {\n  useEventCalendar,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarViewConfig,\n  useEventCalendarViewContext,\n} from \"./event-calendar\";\nimport { markChipPress, useEventCalendarGestures, wasRecentDrag } from \"./event-calendar-dnd\";\nimport { spansMultipleDays, toZoned, zonedStartOfDay } from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type {\n  CalendarView,\n  EventCalendarOccurrence,\n  EventCalendarSegment,\n} from \"./event-calendar-types\";\n\n/** Event color presets; each stays legible on light and dark surfaces. */\nconst EVENT_CALENDAR_COLORS: Array<{ name: string; value: string }> = [\n  { name: \"Blue\", value: \"var(--color-blue-500)\" },\n  { name: \"Emerald\", value: \"var(--color-emerald-500)\" },\n  { name: \"Violet\", value: \"var(--color-violet-500)\" },\n  { name: \"Rose\", value: \"var(--color-rose-500)\" },\n  { name: \"Amber\", value: \"var(--color-amber-500)\" },\n  { name: \"Cyan\", value: \"var(--color-cyan-500)\" },\n  { name: \"Orange\", value: \"var(--color-orange-500)\" },\n  { name: \"Pink\", value: \"var(--color-pink-500)\" },\n  { name: \"Teal\", value: \"var(--color-teal-500)\" },\n  { name: \"Indigo\", value: \"var(--color-indigo-500)\" },\n];\n\n/**\n * Drag-ghost surfaces, shared verbatim by every view. A move CARRIES the\n * event: the dnd engine attaches a full clone to the cursor\n * (data-slot=event-calendar-drag-carry), so this in-grid ghost is only the\n * dashed placeholder for the snapped drop slot. A resize STRETCHES instead:\n * the chip itself at the proposed extent, dashed rather than solid.\n */\nconst EVENT_CALENDAR_GHOST = {\n  move: \"rounded-sm border border-dashed border-(--ec-event-color)/50 bg-(--ec-event-color)/8\",\n  resize: \"rounded-sm border border-dashed border-(--ec-event-color)/70 overflow-hidden\",\n  invalid: \"border-destructive/70 bg-destructive/10\",\n  invalidResize: \"border-destructive/70\",\n  /** Applied to the clone inside an invalid resize ghost. */\n  invalidContent: \"opacity-60\",\n} as const;\n\n/**\n * Fade-out truncation for stacked timed blocks, where squeezed cascade\n * columns clip titles into a mash of glyphs; a right-edge mask fade reads\n * cleaner than an ellipsis at those widths. Masked ONLY below a 10rem\n * container: mask-image forces text off subpixel antialiasing, so masking\n * wide chips makes the whole grid read bolder and shimmer while resizing.\n * Wide chips keep the plain ellipsis. Exported for consumer renderEvent.\n */\nconst EVENT_CALENDAR_FADE_TRUNCATE =\n  \"w-full truncate @max-[10rem]:text-clip @max-[10rem]:[mask-image:linear-gradient(to_right,#000_calc(100%-0.75rem),transparent)] @max-[10rem]:rtl:[mask-image:linear-gradient(to_left,#000_calc(100%-0.75rem),transparent)]\";\n\n/**\n * The drag-to-create selection, shared by every view: a dashed primary\n * outline over a faint wash with the range printed inside. `box` is the timed\n * grid's single minute-positioned rectangle; `segment` is one day-cell slice\n * of a multi-cell draft, with side borders and rounding only on the run's two\n * ends so it reads as one dashed box rather than a row of them.\n */\nconst EVENT_CALENDAR_SLOT_DRAFT = {\n  box: \"rounded-sm border border-dashed border-primary/40 bg-primary/5\",\n  segment: \"border-y border-dashed border-primary/40\",\n  segmentStart: \"rounded-s-sm border-s\",\n  segmentEnd: \"rounded-e-sm border-e\",\n  /**\n   * The wash for segmented views, on the CELL not the dashed overlay: the\n   * overlay stacks above the chips, so tinting it would wash them instead.\n   */\n  surface: \"bg-primary/5\",\n  /**\n   * The range readout. `leading-none` is load-bearing: the shortest timed\n   * draft is one snap interval tall (16px at the default 15-minute snap and\n   * 4rem hour height), and looser leading renders ~18px, clipped by the\n   * draft's own overflow-hidden.\n   */\n  label: \"text-primary truncate px-1 py-0.5 text-[0.6875rem] leading-none font-medium\",\n} as const;\n\ninterface EventCalendarChipContextValue<TData = unknown> {\n  occurrence: EventCalendarOccurrence<TData>;\n  segment: EventCalendarSegment<TData>;\n  isDragging: boolean;\n  isSelected: boolean;\n}\n\nconst EventCalendarChipContext =\n  // biome-ignore lint/suspicious/noExplicitAny: shared across every TData\n  createContext<EventCalendarChipContextValue<any> | null>(null);\n\n/** The chip's subject; usable inside renderEvent content and chip children. */\nfunction useEventCalendarEventChip<TData = unknown>(): EventCalendarChipContextValue<TData> {\n  const ctx = useContext(EventCalendarChipContext);\n  if (!ctx) {\n    throw new Error(\"useEventCalendarEventChip must be used within <EventCalendarEvent>\");\n  }\n  return ctx as EventCalendarChipContextValue<TData>;\n}\n\ninterface EventCalendarEventProps<TData = unknown>\n  extends Omit<React.ComponentProps<\"button\">, \"children\"> {\n  segment: EventCalendarSegment<TData>;\n  /** Replaces the default chip CONTENT; the wrapper stays calendar-owned. */\n  children?: ReactNode;\n  /**\n   * Static drag clone: the chip as-is but inert - no gestures, resize\n   * handles, selection/drag state, focus or pointer events.\n   */\n  preview?: boolean;\n}\n\n/**\n * The one interactive event element in every view. The wrapper owns a11y,\n * data attributes, selection and drag/resize wiring; content comes from\n * children, else renderEvent, else the built-in default.\n */\nfunction EventCalendarEvent<TData = unknown>({\n  segment,\n  className,\n  children,\n  preview = false,\n  ...props\n}: EventCalendarEventProps<TData>) {\n  const instance = useEventCalendar<TData>();\n  const viewConfig = useEventCalendarViewConfig<TData>();\n  const { view } = useEventCalendarViewContext();\n  const gestures = useEventCalendarGestures<TData>();\n  const { settings } = instance;\n  const occurrence = segment.occurrence;\n  const event = occurrence.event;\n\n  const isSelectedRaw = useEventCalendarSelector<TData, boolean>(\n    (state) => state.selection.eventKeys.includes(occurrence.key),\n    { calendar: instance },\n  );\n  const isDraggingRaw = useEventCalendarSelector<TData, boolean>(\n    (state) => state.drag?.occurrence.key === occurrence.key,\n    { calendar: instance },\n  );\n  // reactive, unlike gestures.canResize: api.setInteractions({ resize })\n  // must add/remove the handles without waiting for an unrelated re-render\n  const resizeOn = useEventCalendarSelector<TData, boolean>((state) => state.interactions.resize, {\n    calendar: instance,\n  });\n  // A preview clone must never inherit the source's selected/dragging state\n  // (the drag key matches, which would dim the clone itself).\n  const isSelected = preview ? false : isSelectedRaw;\n  const isDragging = preview ? false : isDraggingRaw;\n\n  const isBar = occurrence.allDay || spansMultipleDays(occurrence, settings.timeZone);\n  const inTimeGrid = view === \"week\" || view === \"day\" || view === \"days\" || view === \"resource\";\n  const interactive = view !== \"agenda\" && !preview;\n  const timedBlock = inTimeGrid && !isBar;\n  const horizontalBar = isBar && !inTimeGrid;\n  // >= compactEventMinutes renders the stacked (title over time) layout, where\n  // squeezed cascade columns fade-truncate instead of clipping into neighbors\n  const stackedBlock =\n    timedBlock && (segment.endMin ?? 0) - (segment.startMin ?? 0) >= viewConfig.compactEventMinutes;\n\n  const defaultContent = (\n    <>\n      {/* leading dot for single-row chips (month cells, all-day bars); a\n          time-grid block takes its color from the tinted surface instead, and\n          in its stacked layout a dot would sit alone on the first line */}\n      {!timedBlock && (\n        <span\n          aria-hidden\n          data-slot=\"event-calendar-event-dot\"\n          // -me-0.5 tightens only the dot-to-title gap; the chip keeps gap-1.5\n          className=\"-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)\"\n        />\n      )}\n      {occurrence.isRecurring && (\n        <RepeatIcon className=\"size-2.5 shrink-0 opacity-70\" aria-hidden=\"true\" />\n      )}\n      <span className={cn(\"font-medium\", stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : \"truncate\")}>\n        {event.title}\n      </span>\n      {/* month cells are narrow: a compact never-shrinking start time keeps\n          the title readable; grids show the full range */}\n      {!occurrence.allDay &&\n        segment.isStart &&\n        (view === \"month\" ? (\n          <span className=\"text-muted-foreground shrink-0\">\n            {format(toZoned(occurrence.start, settings.timeZone), settings.i18n.formats.eventTime, {\n              locale: settings.locale,\n            })}\n          </span>\n        ) : (\n          <span\n            className={cn(\n              \"text-muted-foreground hidden @[8rem]:inline\",\n              stackedBlock ? EVENT_CALENDAR_FADE_TRUNCATE : \"truncate\",\n            )}\n          >\n            {settings.i18n.functions.formatEventTime(\n              toZoned(occurrence.start, settings.timeZone),\n              toZoned(occurrence.end, settings.timeZone),\n              occurrence.allDay,\n              { locale: settings.locale },\n            )}\n          </span>\n        ))}\n    </>\n  );\n\n  // Per-day time text for a multi-day event: \"From 9:00 AM\", \"All day\",\n  // \"Until 5:00 PM\". Boundaries come from the occurrence vs segment.day, never\n  // the packing flags - lane merging rewrites those on shared segments.\n  const agendaTimeText = (() => {\n    if (view !== \"agenda\") return \"\";\n    if (occurrence.allDay) return settings.i18n.labels.allDay;\n    const dayStart = zonedStartOfDay(segment.day, settings.timeZone);\n    const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1);\n    const startsBefore = occurrence.start < dayStart;\n    const endsAfter = occurrence.end > dayEnd;\n    if (startsBefore && endsAfter) return settings.i18n.labels.allDay;\n    if (endsAfter) {\n      return settings.i18n.labels.timeFrom(\n        format(toZoned(occurrence.start, settings.timeZone), settings.i18n.formats.eventTime, {\n          locale: settings.locale,\n        }),\n      );\n    }\n    if (startsBefore) {\n      return settings.i18n.labels.timeUntil(\n        format(toZoned(occurrence.end, settings.timeZone), settings.i18n.formats.eventTime, {\n          locale: settings.locale,\n        }),\n      );\n    }\n    return settings.i18n.functions.formatEventTime(\n      toZoned(occurrence.start, settings.timeZone),\n      toZoned(occurrence.end, settings.timeZone),\n      false,\n      { locale: settings.locale },\n    );\n  })();\n\n  // Agenda default row: time column, color-dot badge, plain title\n  const agendaDefaultContent = (\n    <>\n      <span className=\"text-muted-foreground w-40 shrink-0 truncate tabular-nums\">\n        {agendaTimeText}\n      </span>\n      <span\n        aria-hidden\n        data-slot=\"event-calendar-agenda-dot\"\n        className=\"size-2 shrink-0 rounded-full bg-(--ec-event-color)\"\n      />\n      <span className=\"truncate text-sm\">{event.title}</span>\n      {occurrence.isRecurring && (\n        <RepeatIcon className=\"text-muted-foreground size-2.5 shrink-0\" aria-hidden=\"true\" />\n      )}\n    </>\n  );\n\n  // Memoized so a drag - which re-renders the lane on every pointer move -\n  // never re-invokes the consumer's renderEvent per frame: a referentially\n  // stable element lets React skip the custom subtree instead of flickering it.\n  // The render fns are deps, so an inline arrow from the consumer defeats it.\n  const customContent = useMemo(() => {\n    const renderProps = { occurrence, segment, view, isDragging, isSelected };\n    return view === \"agenda\"\n      ? viewConfig.renderAgendaEvent?.(renderProps)\n      : viewConfig.renderEvent?.(renderProps);\n  }, [\n    occurrence,\n    segment,\n    view,\n    isDragging,\n    isSelected,\n    viewConfig.renderAgendaEvent,\n    viewConfig.renderEvent,\n  ]);\n  const content =\n    children ?? customContent ?? (view === \"agenda\" ? agendaDefaultContent : defaultContent);\n\n  const timeLabel = settings.i18n.functions.formatEventTime(\n    toZoned(occurrence.start, settings.timeZone),\n    toZoned(occurrence.end, settings.timeZone),\n    occurrence.allDay,\n    { locale: settings.locale },\n  );\n  // native hover tooltip text; a formatter returning undefined drops the title\n  const label = settings.i18n.functions.formatEventLabel\n    ? settings.i18n.functions.formatEventLabel(event.title, timeLabel)\n    : `${event.title}, ${timeLabel}`;\n\n  // Optional styled tooltip (viewConfig.eventTooltip, default off). It replaces\n  // the native title so the two never stack, and a preview never gets one. A\n  // falsy renderEventTooltip result (including the false/\"\" of `cond && <node>`)\n  // falls back to the label; an empty label leaves no content and skips it.\n  const tooltipOpts = typeof viewConfig.eventTooltip === \"object\" ? viewConfig.eventTooltip : null;\n  const tooltipContent =\n    !preview && viewConfig.eventTooltip\n      ? viewConfig.renderEventTooltip?.({\n          occurrence,\n          segment,\n          view,\n          label,\n        }) || label\n      : null;\n  const tooltipOn = Boolean(tooltipContent);\n\n  const showResize = interactive && resizeOn && !event.readOnly && event.resizable !== false;\n  // Hover grip pill (mirrors the gantt bars) marking the resize direction.\n  // Shown on compact sub-compactEventMinutes blocks too: the 1.5rem chip\n  // min-height leaves edge room without colliding with the centered title.\n  const grip = (\n    <span\n      aria-hidden\n      data-slot=\"event-calendar-resize-grip\"\n      className={cn(\n        \"bg-foreground/40 rounded-full\",\n        timedBlock ? \"h-0.5 w-2.5\" : \"h-2.5 w-0.5\",\n        viewConfig.classNames?.resizeGrip,\n      )}\n    />\n  );\n  const resizeHandles = showResize && (\n    <>\n      {timedBlock && segment.isStart && (\n        <span\n          data-slot=\"event-calendar-resize-handle\"\n          data-edge=\"start\"\n          className={cn(\n            \"absolute inset-x-1 top-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100\",\n            viewConfig.classNames?.resizeHandle,\n          )}\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"start\")}\n        >\n          {grip}\n        </span>\n      )}\n      {timedBlock && segment.isEnd && (\n        <span\n          data-slot=\"event-calendar-resize-handle\"\n          data-edge=\"end\"\n          className={cn(\n            \"absolute inset-x-1 bottom-0 flex h-1.5 cursor-ns-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100\",\n            viewConfig.classNames?.resizeHandle,\n          )}\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"end\")}\n        >\n          {grip}\n        </span>\n      )}\n      {(horizontalBar || (isBar && inTimeGrid)) && segment.isStart && (\n        <span\n          data-slot=\"event-calendar-resize-handle\"\n          data-edge=\"start\"\n          className={cn(\n            \"absolute inset-y-0 start-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100\",\n            viewConfig.classNames?.resizeHandle,\n          )}\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"start\")}\n        >\n          {grip}\n        </span>\n      )}\n      {(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd && (\n        <span\n          data-slot=\"event-calendar-resize-handle\"\n          data-edge=\"end\"\n          className={cn(\n            \"absolute inset-y-0 end-0 flex w-2 cursor-ew-resize items-center justify-center opacity-0 transition-opacity duration-150 group-hover/ec-event:opacity-100\",\n            viewConfig.classNames?.resizeHandle,\n          )}\n          onPointerDown={(e) => gestures.beginResize(e, segment, \"end\")}\n        >\n          {grip}\n        </span>\n      )}\n    </>\n  );\n\n  const defaultProps = {\n    type: \"button\" as const,\n    \"data-slot\": \"event-calendar-event\",\n    \"data-view\": view,\n    \"data-all-day\": occurrence.allDay || undefined,\n    \"data-recurring\": occurrence.isRecurring || undefined,\n    \"data-selected\": isSelected || undefined,\n    \"data-dragging\": isDragging || undefined,\n    \"data-preview\": preview || undefined,\n    \"data-past\": occurrence.end.getTime() < Date.now() || undefined,\n    title: preview || tooltipOn ? undefined : label,\n    \"aria-label\":\n      settings.i18n.functions.formatEventAriaLabel?.(\n        event.title,\n        timeLabel,\n        segment.continuesBefore || segment.continuesAfter,\n      ) ??\n      `${event.title}, ${timeLabel}${\n        segment.continuesBefore || segment.continuesAfter\n          ? `, ${settings.i18n.labels.continues}`\n          : \"\"\n      }`,\n    // A background tint alone conveys selection, so the chip is a real toggle\n    // wherever it is interactive (agenda rows never select, previews are inert).\n    \"aria-pressed\": interactive ? isSelected : undefined,\n    \"aria-hidden\": preview || undefined,\n    tabIndex: preview ? -1 : undefined,\n    style: {\n      \"--ec-event-color\": event.color ?? \"var(--primary)\",\n    } as CSSProperties,\n    onPointerDown: (e: React.PointerEvent) => {\n      e.stopPropagation();\n      // suppress the trailing slot-create click when this press yields no drag\n      markChipPress();\n      if (interactive) gestures.beginMove(e, segment);\n    },\n    onClick: (e: React.MouseEvent) => {\n      e.stopPropagation();\n      if (wasRecentDrag()) return;\n      // consumer first: e.preventDefault() opts out of built-in selection\n      settings.onEventClick?.(occurrence, e);\n      // the agenda is a read-only list: a click never selects/focuses a row\n      if (e.defaultPrevented || view === \"agenda\") return;\n      instance.api.selectEvent(occurrence.key);\n    },\n    onDoubleClick: (e: React.MouseEvent) => {\n      e.stopPropagation();\n      settings.onEventDoubleClick?.(occurrence, e);\n    },\n    className: cn(\n      \"group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none\",\n      \"focus-visible:ring-ring/50 outline-none focus-visible:ring-2\",\n      preview && \"pointer-events-none\",\n      view === \"agenda\"\n        ? // plain list row: color lives in the dot badge, not a tinted pill;\n          // hover AND selection surfaces are owned by the agenda row wrapper\n          \"gap-3 rounded-md text-sm\"\n        : cn(\n            // @container removes intrinsic sizing; only grid chips are containers\n            // py-1: room above/below inline badges (attendee pill etc.)\n            \"@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal\",\n            // soft tint + inset ring, not an accent border: legible on both themes\n            \"bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25\",\n            // a flat tint reads darker on a dark surface, so lift it there\n            \"dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30\",\n            \"inset-ring inset-ring-(--ec-event-color)/15\",\n            \"transition-[background-color,box-shadow] duration-150\",\n            \"data-dragging:opacity-40\",\n            \"data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40\",\n            segment.continuesBefore && \"rounded-s-none\",\n            segment.continuesAfter && \"rounded-e-none\",\n          ),\n      viewConfig.classNames?.event,\n      className,\n    ),\n    children: (\n      <>\n        {content}\n        {resizeHandles}\n      </>\n    ),\n  };\n\n  const chip = <button {...mergeProps<React.ComponentProps<\"button\">>(defaultProps, props)} />;\n\n  return (\n    <EventCalendarChipContext.Provider value={{ occurrence, segment, isDragging, isSelected }}>\n      {tooltipOn ? (\n        <TooltipTrigger delay={tooltipOpts?.delay ?? 600}>\n          {chip}\n          <Tooltip\n            placement={tooltipOpts?.side ?? \"top\"}\n            className={viewConfig.classNames?.eventTooltip}\n          >\n            {tooltipContent}\n          </Tooltip>\n        </TooltipTrigger>\n      ) : (\n        chip\n      )}\n    </EventCalendarChipContext.Provider>\n  );\n}\n\nexport type { EventCalendarChipContextValue, EventCalendarEventProps };\n\n/**\n * A day's \"+\" button (`showDayAddButton`), always visible: the keyboard way to start creating\n * an event, since drag-to-create needs a pointer. Fires `onSlotClick` with the\n * day as an all-day slot, like clicking the day's empty space.\n */\nfunction EventCalendarDayAddButton({\n  day,\n  view,\n  resourceId,\n  className,\n}: {\n  day: Date;\n  view: CalendarView;\n  resourceId?: string;\n  className?: string;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const date = format(toZoned(day, settings.timeZone), settings.i18n.formats.dayAria, {\n    locale: settings.locale,\n  });\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"event-calendar-day-add\"\n      aria-label={settings.i18n.labels.addEventOn(date)}\n      // a different icon/markup is a renderMonthCell / renderDayHeader job\n      className={cn(\n        // Always visible once enabled (a hover-only reveal hid it from anyone\n        // not pointing at the header); subtle until the day is hovered or it's focused.\n        \"text-muted-foreground flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none transition-colors group-hover/ec-cell:bg-accent group-hover/ec-cell:text-accent-foreground hover:bg-primary hover:text-primary-foreground focus-visible:bg-primary focus-visible:text-primary-foreground focus-visible:ring-2 focus-visible:ring-ring/50\",\n        viewConfig.classNames?.dayAddButton,\n        className,\n      )}\n      onClick={(e) => {\n        e.stopPropagation();\n        settings.onSlotClick?.(\n          { date: zonedStartOfDay(day, settings.timeZone), allDay: true, view, resourceId },\n          e,\n        );\n      }}\n    >\n      <PlusIcon className=\"size-3.5\" aria-hidden=\"true\" />\n    </button>\n  );\n}\n\nexport {\n  EVENT_CALENDAR_COLORS,\n  EVENT_CALENDAR_FADE_TRUNCATE,\n  EVENT_CALENDAR_GHOST,\n  EVENT_CALENDAR_SLOT_DRAFT,\n  EventCalendarDayAddButton,\n  EventCalendarEvent,\n  useEventCalendarEventChip,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-event.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-i18n.tsx",
      "content": "// Title: Event Calendar I18n\n// Description: Default UI texts, date-format strings, and formatter functions for the event calendar, fully overridable per key.\n\nimport { format, isSameMonth, isSameYear, subMilliseconds } from \"@/lib/date-fns-compat/index\";\nimport type { CalendarView, EventCalendarDateRange } from \"./event-calendar-types\";\n\ninterface EventCalendarI18nConfig {\n  labels: {\n    today: string;\n    previous: string;\n    next: string;\n    addEvent: string;\n    /** Aria-label of a day's add button; `date` is formatted with `formats.dayAria`. */\n    addEventOn: (date: string) => string;\n    allDay: string;\n    more: (count: number) => string;\n    noEvents: string;\n    loading: string;\n    event: string;\n    events: (count: number) => string;\n    selectView: string;\n    week: (weekNumber: number) => string;\n    resources: string;\n    goToDate: string;\n    /** Cursor hint while a drag/resize hovers a rejected position. */\n    dropNotAllowed: string;\n    /** Aria-label suffix on chip segments that continue past the cell. */\n    continues: string;\n    /** Agenda label for the first day of a multi-day event. */\n    timeFrom: (time: string) => string;\n    /** Agenda label for the last day of a multi-day event. */\n    timeUntil: (time: string) => string;\n    /** View-switcher shortcut hint characters, per view. */\n    viewShortcuts: Record<CalendarView, string>;\n    /** Aria-label of the agenda day collapse/expand toggle. */\n    toggleDayEvents: (count: number, expanded: boolean) => string;\n    /** Aria-label of the agenda event details toggle. */\n    eventDetails: (title: string) => string;\n    /** Compact \"+N\" overflow (agenda summary dot stack). */\n    moreCompact: (count: number) => string;\n    /** Joins a bounded from-to time span. */\n    timeRange: (from: string, to: string) => string;\n  };\n  viewNames: {\n    month: string;\n    week: string;\n    day: string;\n    days: (count: number) => string;\n    agenda: string;\n    resource: string;\n  };\n  /** date-fns format strings, applied with the calendar `locale`. */\n  formats: {\n    monthTitle: string;\n    /** Undefined = smart cross-month range via functions.formatTitle. */\n    weekTitle?: string;\n    dayTitle: string;\n    /** Undefined = smart range label via functions.formatTitle. */\n    agendaTitle?: string;\n    monthDayHeader: string;\n    /** Narrow variant used by the month view below the compact breakpoint. */\n    monthDayHeaderNarrow: string;\n    timeGridDayHeader: string;\n    agendaDayHeader: string;\n    /** Agenda date-gutter day number. */\n    agendaDayNumber: string;\n    /** Agenda date-gutter weekday label. */\n    agendaWeekday: string;\n    /** \"+N more\" popover day header. */\n    moreDayHeader: string;\n    /** Month cell aria-label date. */\n    monthCellAriaLabel: string;\n    /** Time-grid day column aria-label date. */\n    dayAria: string;\n    /** Undefined = the resource view title falls back to dayTitle. */\n    resourceTitle?: string;\n    timeGutter: string;\n    /** Sub-hour gutter labels (interval below 60 minutes). */\n    timeGutterMinute: string;\n    eventTime: string;\n    monthCellDay: string;\n  };\n  functions: {\n    formatTitle: (\n      view: CalendarView,\n      ctx: {\n        date: Date;\n        activeRange: EventCalendarDateRange;\n        visibleRange: EventCalendarDateRange;\n        locale?: string;\n      },\n    ) => string;\n    formatEventTime: (\n      start: Date,\n      end: Date,\n      allDay: boolean,\n      /** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */\n      opts?: { locale?: string },\n    ) => string;\n    formatDayRange: (range: EventCalendarDateRange, opts?: { locale?: string }) => string;\n    /** Chip native tooltip text; return undefined to drop the attribute. */\n    formatEventLabel?: (title: string, timeLabel: string) => string | undefined;\n    /** Chip aria-label composition. */\n    formatEventAriaLabel?: (title: string, timeLabel: string, continues: boolean) => string;\n  };\n}\n\nconst DEFAULT_LABELS: EventCalendarI18nConfig[\"labels\"] = {\n  today: \"Today\",\n  previous: \"Previous\",\n  next: \"Next\",\n  addEvent: \"Add event\",\n  addEventOn: (date) => `Add event on ${date}`,\n  allDay: \"All day\",\n  more: (count) => `+${count} more`,\n  noEvents: \"No events\",\n  loading: \"Loading events\",\n  event: \"event\",\n  events: (count) => (count === 1 ? \"1 event\" : `${count} events`),\n  selectView: \"Select view\",\n  week: (weekNumber) => `W${weekNumber}`,\n  resources: \"Resources\",\n  goToDate: \"Go to date\",\n  dropNotAllowed: \"Can't place here\",\n  continues: \"continues\",\n  timeFrom: (time) => `From ${time}`,\n  timeUntil: (time) => `Until ${time}`,\n  viewShortcuts: {\n    month: \"M\",\n    week: \"W\",\n    day: \"D\",\n    days: \"5\",\n    agenda: \"A\",\n    resource: \"R\",\n  },\n  toggleDayEvents: (count) => (count === 1 ? \"1 event\" : `${count} events`),\n  eventDetails: (title) => title,\n  moreCompact: (count) => `+${count}`,\n  timeRange: (from, to) => `${from} - ${to}`,\n};\n\nconst DEFAULT_VIEW_NAMES: EventCalendarI18nConfig[\"viewNames\"] = {\n  month: \"Month\",\n  week: \"Week\",\n  day: \"Day\",\n  days: (count) => (count === 1 ? \"1 day\" : `${count} days`),\n  agenda: \"Agenda\",\n  resource: \"Resources\",\n};\n\nconst DEFAULT_FORMATS: EventCalendarI18nConfig[\"formats\"] = {\n  monthTitle: \"MMMM yyyy\",\n  weekTitle: undefined,\n  dayTitle: \"EEEE, MMMM d, yyyy\",\n  agendaTitle: undefined,\n  monthDayHeader: \"EEE\",\n  monthDayHeaderNarrow: \"EEEEE\",\n  timeGridDayHeader: \"EEE d\",\n  agendaDayHeader: \"EEEE, MMMM d\",\n  agendaDayNumber: \"d\",\n  agendaWeekday: \"EEE\",\n  moreDayHeader: \"EEEE, MMMM d\",\n  monthCellAriaLabel: \"PPPP\",\n  dayAria: \"PPPP\",\n  resourceTitle: undefined,\n  timeGutter: \"h a\",\n  timeGutterMinute: \"h:mm a\",\n  eventTime: \"h:mm a\",\n  monthCellDay: \"d\",\n};\n\n/**\n * Default formatting functions BOUND to a config's labels/formats, so that\n * `formats` overrides flow into the default renderers (a consumer overriding\n * formats.monthTitle without replacing formatTitle still sees it applied).\n */\nfunction makeDefaultFunctions(\n  cfg: Pick<EventCalendarI18nConfig, \"labels\" | \"formats\">,\n): EventCalendarI18nConfig[\"functions\"] {\n  return {\n    formatTitle: (view, { date, activeRange, locale }) => {\n      const opts = { locale };\n      if (view === \"month\") {\n        return format(date, cfg.formats.monthTitle, opts);\n      }\n      if (view === \"resource\") {\n        return format(date, cfg.formats.resourceTitle ?? cfg.formats.dayTitle, opts);\n      }\n      if (view === \"day\") {\n        return format(date, cfg.formats.dayTitle, opts);\n      }\n      if (view === \"week\" && cfg.formats.weekTitle) {\n        return format(date, cfg.formats.weekTitle, opts);\n      }\n      if (view === \"agenda\" && cfg.formats.agendaTitle) {\n        return format(date, cfg.formats.agendaTitle, opts);\n      }\n      // week / days / agenda: smart range label, last day is activeRange.end - 1ms.\n      // subMilliseconds keeps the zoned date type (a plain new Date(ms)\n      // would flip the label to the machine zone near midnight)\n      const rangeEnd = subMilliseconds(activeRange.end, 1);\n      const start = activeRange.start;\n      if (isSameMonth(start, rangeEnd)) {\n        return `${format(start, \"MMMM d\", opts)} - ${format(rangeEnd, \"d, yyyy\", opts)}`;\n      }\n      if (isSameYear(start, rangeEnd)) {\n        return `${format(start, \"MMM d\", opts)} - ${format(rangeEnd, \"MMM d, yyyy\", opts)}`;\n      }\n      return `${format(start, \"MMM d, yyyy\", opts)} - ${format(rangeEnd, \"MMM d, yyyy\", opts)}`;\n    },\n    formatEventTime: (start, end, allDay, opts) => {\n      if (allDay) return cfg.labels.allDay;\n      const fmt = cfg.formats.eventTime;\n      // Multi-day timed events carry the date on both sides. Compare calendar\n      // days off the last rendered instant (end is exclusive, so a 14:00 to\n      // midnight event still ends on the start day). Elapsed ms would miss an\n      // exactly-24h event and a DST day that only runs 23 hours.\n      const lastInstant = end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start;\n      if (format(start, \"yyyy-MM-dd\") !== format(lastInstant, \"yyyy-MM-dd\")) {\n        return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`;\n      }\n      return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`;\n    },\n    formatDayRange: (range, opts) => {\n      // subMilliseconds keeps the zoned date type, same reason as formatTitle\n      const rangeEnd = subMilliseconds(range.end, 1);\n      return `${format(range.start, \"MMM d\", opts)} - ${format(rangeEnd, \"MMM d\", opts)}`;\n    },\n  };\n}\n\nconst DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {\n  labels: DEFAULT_LABELS,\n  viewNames: DEFAULT_VIEW_NAMES,\n  formats: DEFAULT_FORMATS,\n  functions: makeDefaultFunctions({\n    labels: DEFAULT_LABELS,\n    formats: DEFAULT_FORMATS,\n  }),\n};\n\n/**\n * One level deeper than `Partial`, because the merge below is per nested\n * section: overriding a single label must not force a consumer to restate the\n * other 22. Unknown keys are still rejected by the excess property check.\n */\ntype EventCalendarI18nOverrides = {\n  [K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>;\n};\n\n/**\n * Shallow merge per nested object, matching the filters.tsx i18n contract:\n * a partial override replaces individual keys, never whole sections. Default\n * functions are re-bound to the MERGED labels/formats; explicit `functions`\n * overrides still win.\n */\nfunction mergeEventCalendarI18n(overrides?: EventCalendarI18nOverrides): EventCalendarI18nConfig {\n  if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N;\n  const labels = { ...DEFAULT_LABELS, ...overrides.labels };\n  const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames };\n  const formats = { ...DEFAULT_FORMATS, ...overrides.formats };\n  return {\n    labels,\n    viewNames,\n    formats,\n    functions: {\n      ...makeDefaultFunctions({ labels, formats }),\n      ...overrides.functions,\n    },\n  };\n}\n\nexport type { EventCalendarI18nConfig, EventCalendarI18nOverrides };\nexport { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-i18n.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-lib.tsx",
      "content": "// Title: Event Calendar Lib\n// Description: Pure, React-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.\n\nimport {\n  addDays,\n  addMonths,\n  addWeeks,\n  differenceInCalendarDays,\n  differenceInMinutes,\n  format,\n  startOfDay,\n  startOfMonth,\n  startOfWeek,\n  TZDate,\n} from \"@/lib/date-fns-compat/index\";\nimport { expandRecurrence } from \"./event-calendar-recurrence\";\nimport type {\n  CalendarEvent,\n  CalendarView,\n  EventCalendarDateRange,\n  EventCalendarOccurrence,\n  EventCalendarOffDaysConfig,\n  EventCalendarResource,\n  EventCalendarSegment,\n} from \"./event-calendar-types\";\n\ntype WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\n/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */\nconst MIN_PACK_SLOT = 30;\n\n/** The instant re-expressed in the display time zone (TZDate extends Date). */\nfunction toZoned(date: Date, timeZone: string): TZDate {\n  return new TZDate(date.getTime(), timeZone);\n}\n\n/** Zoned midnight of the day containing the instant. */\nfunction zonedStartOfDay(date: Date, timeZone: string): TZDate {\n  return startOfDay(toZoned(date, timeZone));\n}\n\n/** Stable per-day key in the display time zone. */\nfunction getDayKey(date: Date, timeZone: string): string {\n  return format(toZoned(date, timeZone), \"yyyy-MM-dd\");\n}\n\n/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */\nfunction getDayTotalMinutes(dayStart: Date, timeZone: string): number {\n  const next = zonedStartOfDay(addDays(toZoned(dayStart, timeZone), 1), timeZone);\n  return differenceInMinutes(next, dayStart);\n}\n\nfunction snapMinutes(minutes: number, snap: number): number {\n  return Math.round(minutes / snap) * snap;\n}\n\ninterface ViewRangeOptions {\n  timeZone: string;\n  weekStartsOn: WeekStartsOn;\n  dayCount: number;\n  agendaDayCount: number;\n  fixedWeeks: boolean;\n}\n\ninterface ViewDateRanges {\n  visibleRange: EventCalendarDateRange;\n  activeRange: EventCalendarDateRange;\n}\n\nfunction getViewDateRange(view: CalendarView, date: Date, opts: ViewRangeOptions): ViewDateRanges {\n  const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts;\n  const zoned = toZoned(date, timeZone);\n\n  if (view === \"month\") {\n    const activeStart = startOfMonth(zoned);\n    const activeEnd = startOfMonth(addMonths(zoned, 1));\n    const visibleStart = startOfWeek(activeStart, { weekStartsOn });\n    let visibleEnd: Date;\n    if (fixedWeeks) {\n      visibleEnd = addDays(visibleStart, 42);\n    } else {\n      visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn });\n      visibleEnd = addWeeks(visibleEnd, 1);\n    }\n    return {\n      activeRange: { start: activeStart, end: activeEnd },\n      visibleRange: { start: visibleStart, end: visibleEnd },\n    };\n  }\n\n  if (view === \"week\") {\n    const start = startOfWeek(zoned, { weekStartsOn });\n    const range = { start, end: addWeeks(start, 1) };\n    return { activeRange: range, visibleRange: range };\n  }\n\n  if (view === \"day\" || view === \"resource\") {\n    const start = startOfDay(zoned);\n    const range = { start, end: addDays(start, 1) };\n    return { activeRange: range, visibleRange: range };\n  }\n\n  if (view === \"days\") {\n    const start = startOfDay(zoned);\n    const range = { start, end: addDays(start, Math.max(1, dayCount)) };\n    return { activeRange: range, visibleRange: range };\n  }\n\n  // agenda\n  const start = startOfDay(zoned);\n  const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) };\n  return { activeRange: range, visibleRange: range };\n}\n\n/** Day of month of the last day of the month containing the zoned date. */\nfunction lastDayOfZonedMonth(date: Date): number {\n  return addDays(startOfMonth(addMonths(date, 1)), -1).getDate();\n}\n\n/** The anchor date stepped one period forward or backward for the view. */\nfunction stepDate(\n  view: CalendarView,\n  date: Date,\n  direction: 1 | -1,\n  opts: Pick<ViewRangeOptions, \"timeZone\" | \"dayCount\" | \"agendaDayCount\">,\n): Date {\n  const zoned = toZoned(date, opts.timeZone);\n  if (view === \"month\") {\n    const stepped = addMonths(zoned, direction);\n    // addMonths clamps the day down into a shorter month and never restores\n    // it, so next-then-prev from the 31st would leave the anchor on the 28th.\n    // Sticking a month end to the target month's end keeps stepping\n    // invertible, which matters because the anchor is what day and week view\n    // open on after a month navigation.\n    if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped;\n    return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate());\n  }\n  if (view === \"week\") return addWeeks(zoned, direction);\n  if (view === \"day\" || view === \"resource\") return addDays(zoned, direction);\n  if (view === \"days\") return addDays(zoned, direction * Math.max(1, opts.dayCount));\n  return addDays(zoned, direction * Math.max(1, opts.agendaDayCount));\n}\n\nfunction rangesIntersect(a: EventCalendarDateRange, b: EventCalendarDateRange): boolean {\n  return a.start < b.end && a.end > b.start;\n}\n\nfunction eventsOverlap(a: { start: Date; end: Date }, b: { start: Date; end: Date }): boolean {\n  return a.start < b.end && a.end > b.start;\n}\n\n/**\n * The one canonical multi-day segmentation. Splits an occurrence into per-day\n * segments clamped to the range. Rules (unit-tested in M1): exclusive end - an\n * event ending exactly at zoned midnight emits NO segment for that day;\n * zero-duration events emit one min-height segment; allDay occurrences walk\n * the same absolute instants as timed ones and only drop startMin/endMin, so\n * their bounds have to already BE display-zone midnights (see\n * CalendarEvent.allDay) or the bar paints on the wrong days.\n */\nfunction segmentOccurrence<TData>(\n  occurrence: EventCalendarOccurrence<TData>,\n  range: EventCalendarDateRange,\n  timeZone: string,\n): EventCalendarSegment<TData>[] {\n  const occStart = occurrence.start;\n  const occEnd = occurrence.end;\n  const isZeroLength = occEnd.getTime() === occStart.getTime();\n\n  const clampStart = occStart > range.start ? occStart : range.start;\n  const clampEnd = occEnd < range.end ? occEnd : range.end;\n  if (clampEnd < clampStart) return [];\n  if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return [];\n\n  const segments: EventCalendarSegment<TData>[] = [];\n  let cursor = zonedStartOfDay(clampStart, timeZone);\n\n  while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {\n    const next = zonedStartOfDay(addDays(toZoned(cursor, timeZone), 1), timeZone);\n    const segStart = clampStart > cursor ? clampStart : cursor;\n    const segEnd = clampEnd < next ? clampEnd : next;\n\n    const emptySeg = segEnd.getTime() <= segStart.getTime();\n    if (!emptySeg || isZeroLength) {\n      const isStart = segStart.getTime() === occStart.getTime();\n      const isEnd = segEnd.getTime() === occEnd.getTime();\n      segments.push({\n        occurrence,\n        day: cursor,\n        isStart,\n        isEnd,\n        continuesBefore: !isStart,\n        continuesAfter: !isEnd,\n        startMin: occurrence.allDay ? undefined : differenceInMinutes(segStart, cursor),\n        endMin: occurrence.allDay\n          ? undefined\n          : Math.max(differenceInMinutes(segEnd, cursor), differenceInMinutes(segStart, cursor)),\n      });\n    }\n    if (isZeroLength) break;\n    cursor = next;\n  }\n\n  return segments;\n}\n\n/** True when the occurrence should render as a bar (all-day row / month lanes). */\nfunction isBarOccurrence(occurrence: EventCalendarOccurrence, timeZone?: string): boolean {\n  return occurrence.allDay || spansMultipleDays(occurrence, timeZone);\n}\n\nfunction spansMultipleDays(occ: { start: Date; end: Date }, timeZone?: string): boolean {\n  // An event ending exactly at the next midnight is still single-day\n  // (exclusive end), so compare against a strictly-later instant. The\n  // yardstick is the length of the day the event starts on, never a flat 24h:\n  // a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one\n  // calendar day that belongs in the hour track, not in the all-day row.\n  // Without a display zone the dates answer in their own frame (TZDate) or in\n  // the host zone.\n  const dayStart = startOfDay(timeZone ? toZoned(occ.start, timeZone) : occ.start);\n  const nextDayStart = startOfDay(addDays(dayStart, 1));\n  return occ.end.getTime() - occ.start.getTime() > nextDayStart.getTime() - dayStart.getTime();\n}\n\n/**\n * Google-style overlap packing for one day's timed segments.\n * Mutates column/columnCount/columnSpan on the segments, in place.\n * z resolution happens at render: event.zIndex verbatim, else 10 + column.\n */\nfunction packTimedSegments<TData>(segments: EventCalendarSegment<TData>[]): void {\n  if (segments.length === 0) return;\n\n  type Working = {\n    seg: EventCalendarSegment<TData>;\n    startMin: number;\n    effEnd: number;\n  };\n\n  const items: Working[] = segments\n    .map((seg) => {\n      const startMin = seg.startMin ?? 0;\n      const endMin = seg.endMin ?? startMin;\n      return {\n        seg,\n        startMin,\n        effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),\n      };\n    })\n    .sort(\n      (a, b) =>\n        a.startMin - b.startMin ||\n        b.effEnd - b.startMin - (a.effEnd - a.startMin) ||\n        a.seg.occurrence.key.localeCompare(b.seg.occurrence.key),\n    );\n\n  // Sweep into connected clusters\n  const clusters: Working[][] = [];\n  let current: Working[] = [];\n  let clusterEnd = -Infinity;\n  for (const item of items) {\n    if (item.startMin >= clusterEnd) {\n      current = [];\n      clusters.push(current);\n      clusterEnd = -Infinity;\n    }\n    current.push(item);\n    clusterEnd = Math.max(clusterEnd, item.effEnd);\n  }\n\n  for (const cluster of clusters) {\n    // Greedy column assignment\n    const colEnds: number[] = [];\n    const byColumn = new Map<number, Working[]>();\n    for (const item of cluster) {\n      let col = colEnds.findIndex((end) => end <= item.startMin);\n      if (col === -1) {\n        col = colEnds.length;\n        colEnds.push(0);\n      }\n      colEnds[col] = item.effEnd;\n      item.seg.column = col;\n      const bucket = byColumn.get(col) ?? [];\n      bucket.push(item);\n      byColumn.set(col, bucket);\n    }\n    const columnCount = colEnds.length;\n\n    // Partial-overlap expansion: widen rightward into free columns\n    for (const item of cluster) {\n      let span = 1;\n      const col = item.seg.column ?? 0;\n      while (col + span < columnCount) {\n        const occupants = byColumn.get(col + span) ?? [];\n        const blocked = occupants.some((o) => o.startMin < item.effEnd && o.effEnd > item.startMin);\n        if (blocked) break;\n        span++;\n      }\n      item.seg.columnCount = columnCount;\n      item.seg.columnSpan = span;\n    }\n  }\n}\n\n/**\n * Greedy lane packing for bar segments within one week row (7 columns).\n * Mutates lane/rowIndex/colStart/colSpan on the segments, in place.\n */\n/**\n * Build the laned month-row bars for one week: consecutive-day segments of\n * the same occurrence merge into ONE bar (colStart -> colSpan) stacked into\n * lanes. Returns NEW segment objects - the shared per-day segments (also\n * rendered by the all-day rows and day cells) must stay pristine: mutating\n * their isEnd/continues flags gave the first-day chip a whole-bar shape and\n * a bogus end resize handle in the week all-day row, where dragging it\n * collapsed the event to a single day.\n */\nfunction packWeekRowLanes<TData>(\n  segments: EventCalendarSegment<TData>[],\n  rowIndex: number,\n  rowStart: Date,\n  timeZone: string,\n): EventCalendarSegment<TData>[] {\n  type Bar = {\n    seg: EventCalendarSegment<TData>;\n    colStart: number;\n    colSpan: number;\n    isStart: boolean;\n    isEnd: boolean;\n    lane: number;\n  };\n\n  const bars: Bar[] = segments.map((seg) => {\n    const dayIndex = Math.round(\n      (zonedStartOfDay(seg.day, timeZone).getTime() -\n        zonedStartOfDay(rowStart, timeZone).getTime()) /\n        (24 * 60 * 60 * 1000),\n    );\n    return {\n      seg,\n      colStart: Math.max(0, Math.min(6, dayIndex)),\n      colSpan: 1,\n      isStart: seg.isStart,\n      isEnd: seg.isEnd,\n      lane: 0,\n    };\n  });\n\n  // Merge consecutive-day segments of the same occurrence into one bar per row\n  const merged = new Map<string, Bar>();\n  for (const bar of bars) {\n    const key = bar.seg.occurrence.key;\n    const existing = merged.get(key);\n    if (existing) {\n      const start = Math.min(existing.colStart, bar.colStart);\n      const end = Math.max(existing.colStart + existing.colSpan, bar.colStart + bar.colSpan);\n      existing.colStart = start;\n      existing.colSpan = end - start;\n      existing.isStart = existing.isStart || bar.isStart;\n      existing.isEnd = existing.isEnd || bar.isEnd;\n    } else {\n      merged.set(key, bar);\n    }\n  }\n\n  const rowBars = Array.from(merged.values()).sort(\n    (a, b) =>\n      a.colStart - b.colStart ||\n      b.colSpan - a.colSpan ||\n      a.seg.occurrence.key.localeCompare(b.seg.occurrence.key),\n  );\n\n  const lanes: boolean[][] = [];\n  for (const bar of rowBars) {\n    let lane = 0;\n    for (;;) {\n      lanes[lane] ??= new Array(7).fill(false);\n      const row = lanes[lane];\n      let free = true;\n      for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {\n        if (row[c]) {\n          free = false;\n          break;\n        }\n      }\n      if (free) break;\n      lane++;\n    }\n    for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {\n      lanes[lane][c] = true;\n    }\n    bar.lane = lane;\n  }\n\n  return rowBars.map((bar) => ({\n    ...bar.seg,\n    isStart: bar.isStart,\n    isEnd: bar.isEnd,\n    continuesBefore: !bar.isStart,\n    continuesAfter: !bar.isEnd,\n    lane: bar.lane,\n    rowIndex,\n    colStart: bar.colStart,\n    colSpan: bar.colSpan,\n  }));\n}\n\ninterface EventCalendarDayBucket<TData = unknown> {\n  allDay: EventCalendarSegment<TData>[];\n  timed: EventCalendarSegment<TData>[];\n}\n\ninterface EventCalendarWeekRow<TData = unknown> {\n  rowIndex: number;\n  rowStart: Date;\n  /** Laned bar segments (one per occurrence per row). */\n  bars: EventCalendarSegment<TData>[];\n}\n\ninterface EventCalendarIndex<TData = unknown> {\n  occurrences: EventCalendarOccurrence<TData>[];\n  byDay: Map<string, EventCalendarDayBucket<TData>>;\n  weekRows: EventCalendarWeekRow<TData>[];\n}\n\ninterface BuildIndexOptions<TData> {\n  timeZone: string;\n  weekStartsOn: WeekStartsOn;\n  eventOrder?: (a: EventCalendarOccurrence<TData>, b: EventCalendarOccurrence<TData>) => number;\n  getOccurrences?: (\n    event: CalendarEvent<TData>,\n    range: EventCalendarDateRange,\n    ctx: { timeZone: string },\n  ) => Array<{ start: Date; end: Date }> | null;\n}\n\nfunction defaultEventOrder(a: EventCalendarOccurrence, b: EventCalendarOccurrence): number {\n  return (\n    a.start.getTime() - b.start.getTime() ||\n    b.end.getTime() - b.start.getTime() - (a.end.getTime() - a.start.getTime()) ||\n    a.key.localeCompare(b.key)\n  );\n}\n\nfunction buildEventIndex<TData>(\n  events: CalendarEvent<TData>[],\n  visibleRange: EventCalendarDateRange,\n  opts: BuildIndexOptions<TData>,\n): EventCalendarIndex<TData> {\n  const { timeZone, weekStartsOn } = opts;\n  const order = opts.eventOrder ?? defaultEventOrder;\n\n  // RECURRENCE-ID override replacement: an event carrying recurringEventId +\n  // originalStart is an edited single occurrence of that series. The parent's\n  // expansion drops the replaced instant; the override renders as its own\n  // occurrence through the normal path below.\n  const overrideTimes = new Map<string, Set<number>>();\n  for (const event of events) {\n    if (!event.recurringEventId || !event.originalStart) continue;\n    let times = overrideTimes.get(event.recurringEventId);\n    if (!times) {\n      times = new Set();\n      overrideTimes.set(event.recurringEventId, times);\n    }\n    times.add(event.originalStart.getTime());\n  }\n\n  const occurrences: EventCalendarOccurrence<TData>[] = [];\n  for (const event of events) {\n    const replaced = overrideTimes.get(event.id);\n    const custom = opts.getOccurrences?.(event, visibleRange, { timeZone });\n    if (custom) {\n      custom.forEach((occ, i) => {\n        if (replaced?.has(occ.start.getTime())) return;\n        if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange)) return;\n        occurrences.push({\n          key: `${event.id}::${occ.start.toISOString()}`,\n          eventId: event.id,\n          event,\n          start: occ.start,\n          end: occ.end,\n          allDay: event.allDay ?? false,\n          isRecurring: true,\n          recurrenceIndex: i,\n        });\n      });\n      continue;\n    }\n    const expanded = expandRecurrence(event, visibleRange, { timeZone });\n    occurrences.push(\n      ...(replaced ? expanded.filter((occ) => !replaced.has(occ.start.getTime())) : expanded),\n    );\n  }\n  occurrences.sort(order);\n\n  const byDay = new Map<string, EventCalendarDayBucket<TData>>();\n  const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>();\n  const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {\n    weekStartsOn,\n  });\n\n  for (const occurrence of occurrences) {\n    const segments = segmentOccurrence(occurrence, visibleRange, timeZone);\n    const bar = isBarOccurrence(occurrence, timeZone);\n    for (const seg of segments) {\n      const key = getDayKey(seg.day, timeZone);\n      let bucket = byDay.get(key);\n      if (!bucket) {\n        bucket = { allDay: [], timed: [] };\n        byDay.set(key, bucket);\n      }\n      if (bar) {\n        bucket.allDay.push(seg);\n        // calendar-day math, not a fixed 168h divisor: DST transition weeks\n        // are 167/169h long and the fixed divisor mis-buckets every later\n        // Sunday one row early (which then clamps into the wrong column)\n        const rowIndex = Math.floor(\n          differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) / 7,\n        );\n        const rowBucket = barSegmentsByRow.get(rowIndex) ?? [];\n        rowBucket.push(seg);\n        barSegmentsByRow.set(rowIndex, rowBucket);\n      } else {\n        bucket.timed.push(seg);\n      }\n    }\n  }\n\n  for (const bucket of byDay.values()) {\n    packTimedSegments(bucket.timed);\n  }\n\n  const weekRows: EventCalendarWeekRow<TData>[] = [];\n  for (const [rowIndex, segs] of barSegmentsByRow) {\n    const rowStart = addWeeks(firstRowStart, rowIndex);\n    weekRows.push({\n      rowIndex,\n      rowStart,\n      bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),\n    });\n  }\n  weekRows.sort((a, b) => a.rowIndex - b.rowIndex);\n\n  return { occurrences, byDay, weekRows };\n}\n\n/** Cache key for index memoization; cheap string compare. */\nfunction getRangeKey(range: EventCalendarDateRange): string {\n  return `${range.start.getTime()}-${range.end.getTime()}`;\n}\n\n/** Depth-first flatten of the resource tree (parents included). */\nfunction flattenResources(\n  resources: EventCalendarResource[],\n  depth = 0,\n): Array<{ resource: EventCalendarResource; depth: number }> {\n  const rows: Array<{ resource: EventCalendarResource; depth: number }> = [];\n  for (const resource of resources) {\n    rows.push({ resource, depth });\n    if (resource.children?.length) {\n      rows.push(...flattenResources(resource.children, depth + 1));\n    }\n  }\n  return rows;\n}\n\nconst DEFAULT_WEEKEND_DAYS = [0, 6];\n\n/**\n * Resolves whether a day is an off day (non-working) in the display zone.\n * Callers pass the calendar's own weekendDays so the shading cannot contradict\n * the weekend the rest of the calendar renders; an explicit offDays.weekendDays\n * still wins over it.\n */\nfunction resolveOffDay(\n  day: Date,\n  timeZone: string,\n  config: boolean | EventCalendarOffDaysConfig | undefined,\n  defaultWeekendDays?: number[],\n): boolean {\n  if (!config) return false;\n  const resolved: EventCalendarOffDaysConfig = config === true ? {} : config;\n  const weekendDays = resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS;\n  const zoned = toZoned(day, timeZone);\n  if (weekendDays.includes(zoned.getDay())) return true;\n  if (resolved.dates?.length) {\n    const key = getDayKey(day, timeZone);\n    if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {\n      return true;\n    }\n  }\n  return resolved.isOffDay?.(day) ?? false;\n}\n\nexport type {\n  BuildIndexOptions,\n  EventCalendarDayBucket,\n  EventCalendarIndex,\n  EventCalendarWeekRow,\n  ViewDateRanges,\n  ViewRangeOptions,\n  WeekStartsOn,\n};\nexport {\n  buildEventIndex,\n  defaultEventOrder,\n  eventsOverlap,\n  flattenResources,\n  getDayKey,\n  getDayTotalMinutes,\n  getRangeKey,\n  getViewDateRange,\n  isBarOccurrence,\n  MIN_PACK_SLOT,\n  packTimedSegments,\n  packWeekRowLanes,\n  rangesIntersect,\n  resolveOffDay,\n  segmentOccurrence,\n  snapMinutes,\n  spansMultipleDays,\n  stepDate,\n  toZoned,\n  zonedStartOfDay,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-lib.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-merge-props.ts",
      "content": "import { cn } from \"cn\";\nimport type * as React from \"react\";\n\ntype Props = Record<string, unknown>;\n\nfunction mergeRefs<T>(...refs: React.Ref<T>[]): React.RefCallback<T> {\n  return (node) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) (ref as React.RefObject<T | null>).current = node;\n    }\n  };\n}\n\n/**\n * Merges the calendar's own props with the consumer's: later values win,\n * except `className` (joined with `cn`), `style` (shallow-merged), event\n * handlers (all called, in order) and `ref`s (all set).\n */\nfunction mergeProps<T extends object>(...sources: Array<object | undefined>): T {\n  const result: Props = {};\n  for (const source of sources) {\n    if (!source) continue;\n    for (const [key, value] of Object.entries(source)) {\n      const previous = result[key];\n      if (value === undefined) continue;\n      if (key === \"className\") {\n        result[key] = cn(previous as string | undefined, value as string);\n      } else if (key === \"style\") {\n        result[key] = {\n          ...(previous as object | undefined),\n          ...(value as object),\n        };\n      } else if (\n        /^on[A-Z]/.test(key) &&\n        typeof previous === \"function\" &&\n        typeof value === \"function\"\n      ) {\n        result[key] = (...args: unknown[]) => {\n          previous(...args);\n          value(...args);\n        };\n      } else if (key === \"ref\" && previous) {\n        result[key] = mergeRefs(previous as React.Ref<unknown>, value as React.Ref<unknown>);\n      } else {\n        result[key] = value;\n      }\n    }\n  }\n  return result as T;\n}\n\nexport { mergeProps };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-merge-props.ts"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-month-view.tsx",
      "content": "// Title: Event Calendar Month View\n// Description: ARIA-grid month view with week rows, day cells, event chips, and overflow counts.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport type * as React from \"react\";\nimport {\n  type CSSProperties,\n  type Ref,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Dialog } from \"react-aria-components\";\nimport { Popover } from \"@/core/popover/popover\";\nimport { ScrollArea } from \"@/core/scroll-area/scroll-area\";\nimport { addDays, format, getWeek } from \"@/lib/date-fns-compat/index\";\nimport {\n  EventCalendarViewContext,\n  useEventCalendar,\n  useEventCalendarDay,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarViewConfig,\n  useEventCalendarViewSettings,\n  useEventCalendarWeek,\n} from \"./event-calendar\";\nimport { useEventCalendarGestures, wasRecentChipPress, wasRecentDrag } from \"./event-calendar-dnd\";\nimport {\n  EVENT_CALENDAR_GHOST,\n  EVENT_CALENDAR_SLOT_DRAFT,\n  EventCalendarDayAddButton,\n  EventCalendarEvent,\n} from \"./event-calendar-event\";\nimport {\n  getDayKey,\n  getRangeKey,\n  resolveOffDay,\n  toZoned,\n  zonedStartOfDay,\n} from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type {\n  EventCalendarDateRange,\n  EventCalendarDragState,\n  EventCalendarEventId,\n  EventCalendarSegment,\n} from \"./event-calendar-types\";\n\n// Layout-effect on the client (measure before paint, no flash), plain effect on\n// the server (never runs there) to avoid the SSR useLayoutEffect warning.\nconst useIsoLayoutEffect = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// An occurrence key encodes the start instant and is also the chip's React key,\n// so committing a move re-keys the chip: React remounts it and the browser\n// drops focus to <body>. The chip that owns focus is recorded here so the cell\n// rendering its replacement can hand focus back. Module scope because the drop\n// can land in a different cell than the one the chip left, and only one element\n// holds focus at a time anyway.\nlet focusedChip: {\n  node: HTMLElement;\n  eventId: EventCalendarEventId;\n  recurrenceIndex?: number;\n} | null = null;\n\n/** Give focus back to the recorded chip's replacement, if `root` renders it. */\nfunction restoreChipFocus(root: HTMLElement | null, segments: EventCalendarSegment[]) {\n  const pending = focusedChip;\n  // Only a chip removed WHILE focused needs help: a node still in the tree, or\n  // a focus that has already moved on by itself, is left alone.\n  if (!root || !pending || pending.node.isConnected) return;\n  const active = document.activeElement;\n  if (active && active !== document.body) return;\n  const index = segments.findIndex(\n    (segment) =>\n      segment.occurrence.eventId === pending.eventId &&\n      segment.occurrence.recurrenceIndex === pending.recurrenceIndex,\n  );\n  if (index < 0) return;\n  const chip = root.querySelectorAll<HTMLElement>(\"[data-slot=event-calendar-event]\")[index];\n  if (!chip) return;\n  // cleared first: focus() re-records through the new chip's own onFocus\n  focusedChip = null;\n  chip.focus();\n}\n\ninterface EventCalendarMonthViewProps extends React.ComponentProps<\"div\"> {\n  maxEventsPerCell?: number | \"auto\";\n}\n\nfunction EventCalendarMonthView({\n  className,\n  maxEventsPerCell,\n  ...props\n}: EventCalendarMonthViewProps) {\n  const instance = useEventCalendar();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const visibleRange = useEventCalendarSelector<unknown, EventCalendarDateRange>(\n    (state) => state.visibleRange,\n    {\n      isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),\n    },\n  );\n  const anchorDate = useEventCalendarSelector((state) => state.date);\n\n  const { effective } = useEventCalendarViewSettings();\n  const weeks = useMemo(() => {\n    const days: Date[] = [];\n    let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone);\n    while (cursor < visibleRange.end) {\n      days.push(cursor);\n      cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone);\n    }\n    const rows: Date[][] = [];\n    for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7));\n    if (effective.weekends) return rows;\n    return rows.map((row) =>\n      row.filter((day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay())),\n    );\n  }, [visibleRange, settings.timeZone, settings.weekendDays, effective.weekends]);\n\n  const headerDays = weeks[0] ?? [];\n  const title = settings.i18n.functions.formatTitle(\"month\", {\n    date: toZoned(anchorDate, settings.timeZone),\n    activeRange: instance.api.getActiveRange(),\n    visibleRange,\n    locale: settings.locale,\n  });\n\n  const gridTemplateColumns = `${effective.weekNumbers ? \"var(--ec-week-number-w, 2.75rem) \" : \"\"}repeat(${headerDays.length}, minmax(0, 1fr))`;\n  const cap = maxEventsPerCell ?? viewConfig.maxEventsPerCell;\n  const contained = viewConfig.scrollMode !== \"page\";\n\n  // \"auto\" fits as many event rows as the cell height allows and rolls the rest\n  // into \"+N more\". Only the contained mode gives a cell a bounded height to\n  // measure; page mode grows to fit, so \"auto\" there keeps the fixed fallback.\n  const autoFit = cap === \"auto\" && contained;\n  // slotProbe resolves the event-row height (--ec-month-bar-h) to px, honoring\n  // the current font size and any consumer override; contentProbe is the first\n  // cell's flex-1 content area, whose height is the event space per cell.\n  const slotProbeRef = useRef<HTMLDivElement | null>(null);\n  const contentProbeRef = useRef<HTMLDivElement | null>(null);\n  const [autoCap, setAutoCap] = useState<number | null>(null);\n  const measureCap = useCallback(() => {\n    const content = contentProbeRef.current;\n    const slot = slotProbeRef.current;\n    if (!content || !slot) return;\n    const laneH = slot.getBoundingClientRect().height;\n    if (laneH <= 0) return;\n    const cs = getComputedStyle(content);\n    const inner = content.clientHeight - (parseFloat(cs.paddingTop) || 0);\n    const gap = parseFloat(cs.rowGap) || 0;\n    // N rows occupy N*laneH - gap (the last row has no trailing gap)\n    setAutoCap(Math.max(1, Math.floor((inner + gap) / laneH)));\n  }, []);\n  useIsoLayoutEffect(() => {\n    if (!autoFit) {\n      setAutoCap(null);\n      return;\n    }\n    const content = contentProbeRef.current;\n    if (!content || typeof ResizeObserver === \"undefined\") return;\n    measureCap();\n    const observer = new ResizeObserver(measureCap);\n    observer.observe(content);\n    return () => observer.disconnect();\n    // re-observe the first cell after a re-layout (row count or month change)\n  }, [autoFit, measureCap, weeks.length, anchorDate]);\n  const resolvedCap = cap === \"auto\" ? (autoFit ? (autoCap ?? 3) : 3) : cap;\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-month-view\",\n    \"data-view\": \"month\",\n    role: \"grid\",\n    \"aria-label\": title,\n    className: cn(\n      \"flex flex-col border-t\",\n      contained && \"min-h-0 flex-1 overflow-hidden\",\n      viewConfig.classNames?.monthView,\n      className,\n    ),\n    children: (\n      <>\n        <div\n          role=\"row\"\n          data-slot=\"event-calendar-month-header\"\n          // @container scopes the narrow-label breakpoint to the header row\n          className={cn(\"@container grid border-b\", viewConfig.classNames?.monthHeader)}\n          style={{ gridTemplateColumns }}\n        >\n          {effective.weekNumbers && (\n            <div\n              role=\"columnheader\"\n              aria-hidden\n              className={cn(\"border-e px-2 py-1.5\", viewConfig.classNames?.weekNumber)}\n            />\n          )}\n          {headerDays.map((day) => (\n            <div\n              key={day.getTime()}\n              role=\"columnheader\"\n              className={cn(\n                \"text-muted-foreground truncate px-2 py-1.5 font-medium\",\n                viewConfig.classNames?.monthDayHeader,\n              )}\n            >\n              {viewConfig.renderDayHeader?.({\n                day,\n                view: \"month\",\n                isToday:\n                  getDayKey(day, settings.timeZone) === getDayKey(new Date(), settings.timeZone),\n              }) ?? (\n                <>\n                  <span className=\"@max-[36rem]:hidden\">\n                    {format(toZoned(day, settings.timeZone), settings.i18n.formats.monthDayHeader, {\n                      locale: settings.locale,\n                    })}\n                  </span>\n                  <span className=\"hidden @max-[36rem]:inline\">\n                    {format(\n                      toZoned(day, settings.timeZone),\n                      settings.i18n.formats.monthDayHeaderNarrow,\n                      { locale: settings.locale },\n                    )}\n                  </span>\n                </>\n              )}\n            </div>\n          ))}\n        </div>\n        <div\n          data-slot=\"event-calendar-month-body\"\n          className={cn(\"grid\", contained && \"min-h-0 flex-1\", viewConfig.classNames?.monthBody)}\n          style={{\n            gridTemplateRows: contained\n              ? `repeat(${weeks.length}, minmax(0, 1fr))`\n              : `repeat(${weeks.length}, minmax(var(--ec-month-row-min-h, 8rem), auto))`,\n          }}\n        >\n          {weeks.map((week, rowIndex) => (\n            <EventCalendarMonthWeek\n              key={week[0]?.getTime() ?? rowIndex}\n              week={week}\n              gridTemplateColumns={gridTemplateColumns}\n              showWeekNumber={effective.weekNumbers}\n              cap={resolvedCap}\n              autoFit={autoFit}\n              contentRef={rowIndex === 0 ? contentProbeRef : undefined}\n            />\n          ))}\n        </div>\n        {autoFit && (\n          <div\n            ref={slotProbeRef}\n            aria-hidden\n            className=\"pointer-events-none invisible absolute h-[var(--ec-month-bar-h,1.75rem)] w-0\"\n          />\n        )}\n      </>\n    ),\n  };\n\n  return (\n    <EventCalendarViewContext.Provider value={{ view: \"month\" }}>\n      <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />\n    </EventCalendarViewContext.Provider>\n  );\n}\n\n/**\n * One month week row. Multi-day / all-day events render as CONTINUOUS bars in\n * an overlay grid that spans day columns (colStart -> colSpan) and stacks by\n * lane; single-day timed events render inside each cell below the reserved bar\n * lanes. This is what makes a cross-day event read as one whole block instead\n * of a chip repeated per cell.\n */\nfunction EventCalendarMonthWeek({\n  week,\n  gridTemplateColumns,\n  showWeekNumber,\n  cap,\n  autoFit,\n  contentRef,\n}: {\n  week: Date[];\n  gridTemplateColumns: string;\n  showWeekNumber: boolean;\n  cap: number;\n  autoFit: boolean;\n  /** Set on the first week only: forwarded to its first cell's content area so\n   *  the view can measure the per-cell event height for \"auto\". */\n  contentRef?: Ref<HTMLDivElement>;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { bars, rowStart } = useEventCalendarWeek(week[0]);\n  const colOffset = showWeekNumber ? 1 : 0;\n  const dayMs = 86400000;\n  const rowStartMs = zonedStartOfDay(rowStart ?? week[0], settings.timeZone).getTime();\n  // Day offsets from the TRUE row start (0-6) for each visible column, so a\n  // weekends-hidden month still places bars on the right days.\n  const offsets = week.map((d) =>\n    Math.round((zonedStartOfDay(d, settings.timeZone).getTime() - rowStartMs) / dayMs),\n  );\n  /** Clamp a day-offset span onto the visible columns; null = fully hidden. */\n  const gridPos = (colStart: number, colSpan: number) => {\n    let start = -1;\n    let end = -1;\n    for (let o = colStart; o < colStart + colSpan; o++) {\n      const col = offsets.indexOf(o);\n      if (col === -1) continue;\n      if (start === -1) start = col;\n      end = col;\n    }\n    return start === -1 ? null : { col: start, span: end - start + 1 };\n  };\n  // bars fit within the cap; deeper lanes fall into each day's \"+N more\"\n  const visibleBars = bars.filter((b) => (b.lane ?? 0) < cap);\n  const covers = (b: EventCalendarSegment, dayOffset: number) =>\n    (b.colStart ?? 0) <= dayOffset && dayOffset < (b.colStart ?? 0) + (b.colSpan ?? 1);\n  // Occurrence keys of the bars hidden in each column (lane >= cap). Threaded to\n  // the cell so its \"+N more\" popover can list the hidden bars WITHOUT re-listing\n  // the visible ones (day buckets carry no lane, so the week row - which owns bar\n  // laning - is the only place that knows which bars are hidden).\n  const hiddenBarKeysByCol = week.map(\n    (_, col) =>\n      new Set(\n        bars\n          .filter((b) => (b.lane ?? 0) >= cap && covers(b, offsets[col]))\n          .map((b) => b.occurrence.key),\n      ),\n  );\n\n  // Live move/resize ghost at the PROPOSED day span. Standardized treatment\n  // (EVENT_CALENDAR_GHOST): move = the event carried as a full clone, resize =\n  // the same clone with a dashed boundary; invalid adds destructive marking\n  // while the engine shows the not-allowed cursor + validation hint.\n  const dragGhost = useEventCalendarSelector<\n    unknown,\n    | (Pick<\n        EventCalendarDragState,\n        \"kind\" | \"valid\" | \"occurrence\" | \"proposedStart\" | \"proposedEnd\" | \"proposedAllDay\"\n      > & {\n        colStart: number;\n        colSpan: number;\n        isStart: boolean;\n        isEnd: boolean;\n        color?: string;\n      })\n    | null\n  >(\n    (state) => {\n      const drag = state.drag;\n      if (!drag) return null;\n      const rowEndMs = rowStartMs + 7 * dayMs;\n      const startDayMs = zonedStartOfDay(drag.proposedStart, settings.timeZone).getTime();\n      // exclusive end -> the last covered day\n      const lastDayMs = zonedStartOfDay(\n        new Date(drag.proposedEnd.getTime() - 1),\n        settings.timeZone,\n      ).getTime();\n      if (startDayMs >= rowEndMs || lastDayMs < rowStartMs) return null;\n      const startCol = Math.max(0, Math.round((startDayMs - rowStartMs) / dayMs));\n      const endCol = Math.min(6, Math.round((lastDayMs - rowStartMs) / dayMs));\n      if (endCol < startCol) return null;\n      return {\n        kind: drag.kind,\n        valid: drag.valid,\n        occurrence: drag.occurrence,\n        proposedStart: drag.proposedStart,\n        proposedEnd: drag.proposedEnd,\n        proposedAllDay: drag.proposedAllDay,\n        colStart: startCol,\n        colSpan: endCol - startCol + 1,\n        isStart: startDayMs >= rowStartMs,\n        isEnd: lastDayMs < rowEndMs,\n        color: drag.occurrence.event.color,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.colStart === b.colStart &&\n          a.colSpan === b.colSpan &&\n          a.valid === b.valid &&\n          a.kind === b.kind &&\n          a.proposedAllDay === b.proposedAllDay &&\n          a.proposedStart.getTime() === b.proposedStart.getTime() &&\n          a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n    },\n  );\n  const ghostPos = dragGhost ? gridPos(dragGhost.colStart, dragGhost.colSpan) : null;\n  const ghostLane = dragGhost\n    ? (bars.find((b) => b.occurrence.key === dragGhost.occurrence.key)?.lane ?? 0)\n    : 0;\n  // A single-day timed MOVE is indicated INLINE in the target cell (a\n  // placeholder at the time-sorted position, rendered by EventCalendarMonthCell)\n  // rather than as a bar in this overlay, so only bar drags and every resize\n  // render the overlay ghost. Bars = all-day or multi-day (span > 1 day).\n  const ghostIsBar =\n    !!dragGhost &&\n    (dragGhost.kind !== \"move\" ||\n      dragGhost.proposedAllDay ||\n      dragGhost.proposedEnd.getTime() - dragGhost.proposedStart.getTime() > dayMs);\n\n  return (\n    <div\n      role=\"row\"\n      data-slot=\"event-calendar-month-row\"\n      className={cn(\n        \"relative grid min-h-0 border-b last:border-b-0\",\n        viewConfig.classNames?.monthRow,\n      )}\n      style={{ gridTemplateColumns }}\n    >\n      {showWeekNumber && (\n        <div\n          role=\"rowheader\"\n          data-slot=\"event-calendar-week-number\"\n          className={cn(\n            \"text-muted-foreground border-e px-2 pt-1 tabular-nums\",\n            viewConfig.classNames?.weekNumber,\n          )}\n        >\n          {settings.i18n.labels.week(\n            getWeek(toZoned(week[0], settings.timeZone), {\n              // locale supplies firstWeekContainsDate, so a de/ISO calendar\n              // numbers the year-boundary weeks its own way instead of falling\n              // back to US numbering; weekStartsOn stays explicit so the number\n              // keeps matching the rendered grid\n              locale: settings.locale,\n              weekStartsOn: settings.weekStartsOn,\n            }),\n          )}\n        </div>\n      )}\n      {week.map((day, col) => (\n        <EventCalendarMonthCell\n          key={day.getTime()}\n          day={day}\n          cap={cap}\n          // Reserve lane space only for bars that pass through THIS cell, so a\n          // short multi-day event does not push down timed events in unrelated\n          // cells of the same row. Reserve down to the deepest covering bar\n          // lane so timed events always sit below every bar in their own cell.\n          reservedLanes={visibleBars.reduce(\n            (max, b) => (covers(b, offsets[col]) ? Math.max(max, (b.lane ?? 0) + 1) : max),\n            0,\n          )}\n          hiddenBarKeys={hiddenBarKeysByCol[col]}\n          isLast={col === week.length - 1}\n          autoFit={autoFit}\n          contentRef={col === 0 ? contentRef : undefined}\n        />\n      ))}\n      {/* Continuous bar overlay: one element per bar, placed by grid-column so\n          a cross-day span is a single unbroken block. pointer-events pass\n          through the gaps to the cells below. NOT aria-hidden - these are the\n          real interactive bars. */}\n      {(visibleBars.length > 0 || (dragGhost && ghostPos && ghostIsBar)) && (\n        <div\n          data-slot=\"event-calendar-month-bar-overlay\"\n          className={cn(\n            \"pointer-events-none absolute inset-x-0 top-0 z-10 grid pt-1.5\",\n            viewConfig.classNames?.monthBarOverlay,\n          )}\n          style={{\n            gridTemplateColumns,\n            gridAutoRows: \"var(--ec-month-bar-h, 1.75rem)\",\n          }}\n        >\n          {visibleBars.map((bar) => {\n            const pos = gridPos(bar.colStart ?? 0, bar.colSpan ?? 1);\n            if (!pos) return null;\n            return (\n              <div\n                key={bar.occurrence.key}\n                className={cn(\"pointer-events-auto min-w-0 px-1\", viewConfig.classNames?.monthBar)}\n                style={{\n                  gridColumn: `${colOffset + pos.col + 1} / span ${pos.span}`,\n                  gridRow: (bar.lane ?? 0) + 1,\n                }}\n              >\n                {/* lane height minus the 2px inter-lane gap */}\n                <EventCalendarEvent\n                  segment={bar}\n                  className=\"h-[calc(var(--ec-month-bar-h,1.75rem)-0.125rem)]\"\n                />\n              </div>\n            );\n          })}\n          {dragGhost && ghostPos && ghostIsBar && (\n            <div\n              aria-hidden\n              className={cn(\"min-w-0 px-1\", viewConfig.classNames?.monthBar)}\n              style={{\n                gridColumn: `${colOffset + ghostPos.col + 1} / span ${ghostPos.span}`,\n                gridRow: ghostLane + 1,\n              }}\n            >\n              <div\n                data-slot=\"event-calendar-drag-ghost\"\n                data-kind={dragGhost.kind}\n                data-drop-invalid={!dragGhost.valid || undefined}\n                className={cn(\n                  \"h-[calc(var(--ec-month-bar-h,1.75rem)-0.125rem)]\",\n                  dragGhost.kind === \"move\"\n                    ? // faint drop placeholder only: the cursor-attached\n                      // carry clone owns the visual during moves\n                      cn(\n                        EVENT_CALENDAR_GHOST.move,\n                        !dragGhost.valid && EVENT_CALENDAR_GHOST.invalid,\n                      )\n                    : cn(\n                        EVENT_CALENDAR_GHOST.resize,\n                        !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize,\n                      ),\n                  viewConfig.classNames?.dragGhost,\n                )}\n                style={\n                  {\n                    \"--ec-event-color\": dragGhost.color ?? \"var(--primary)\",\n                  } as CSSProperties\n                }\n              >\n                {dragGhost.kind !== \"move\" && (\n                  <EventCalendarEvent\n                    preview\n                    segment={{\n                      occurrence: {\n                        ...dragGhost.occurrence,\n                        start: dragGhost.proposedStart,\n                        end: dragGhost.proposedEnd,\n                      },\n                      day: week[ghostPos.col] ?? week[0],\n                      isStart: dragGhost.isStart,\n                      isEnd: dragGhost.isEnd,\n                      continuesBefore: !dragGhost.isStart,\n                      continuesAfter: !dragGhost.isEnd,\n                    }}\n                    className={cn(\n                      \"h-full inset-ring-0\",\n                      !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent,\n                    )}\n                  />\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction EventCalendarMonthCell({\n  day,\n  cap,\n  reservedLanes,\n  hiddenBarKeys,\n  isLast,\n  autoFit,\n  contentRef,\n}: {\n  day: Date;\n  cap: number;\n  reservedLanes: number;\n  /** Occurrence keys of the bars hidden in THIS column (lane >= cap), from the\n   *  week row. Lets the cell list hidden bars in its overflow popover without\n   *  re-listing the bars already visible in the row overlay. */\n  hiddenBarKeys: Set<string>;\n  /** Last column in the row - drops the right border so the grid's outer edge\n   *  is owned by the container, not a doubled cell border. Passed explicitly\n   *  because the bar overlay renders after the cells, so `:last-child` is\n   *  unreliable on rows that have bars. */\n  isLast: boolean;\n  /** When true, the \"+N more\" chip is treated as taking a row so the visible\n   *  chips + indicator always fit the measured cell height. */\n  autoFit: boolean;\n  /** Set on the first cell only: measured to derive the \"auto\" cap. */\n  contentRef?: Ref<HTMLDivElement>;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const gestures = useEventCalendarGestures();\n  const { segments, isToday, isOutside } = useEventCalendarDay(day);\n\n  const dayStart = zonedStartOfDay(day, settings.timeZone);\n  const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1);\n  const { effective } = useEventCalendarViewSettings();\n  const isOff = resolveOffDay(\n    day,\n    settings.timeZone,\n    effective.offDays\n      ? typeof viewConfig.offDays === \"object\"\n        ? viewConfig.offDays\n        : true\n      : false,\n    settings.weekendDays,\n  );\n  const offClassName =\n    (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) || \"bg-muted/25\";\n\n  const isDropTarget = useEventCalendarSelector<unknown, \"valid\" | \"invalid\" | null>((state) => {\n    const drag = state.drag;\n    if (!drag) return null;\n    const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart;\n    if (!covered) return null;\n    return drag.valid ? \"valid\" : \"invalid\";\n  });\n  // Hide hover affordances mid-gesture: the only intent is the drop target.\n  // Gated on the one thing that reads it: a plain global boolean flips for all\n  // 42 cells the moment a gesture starts and again when it ends, which is pure\n  // waste in the default configuration where no add button renders.\n  const isInteracting = useEventCalendarSelector<unknown, boolean>((state) =>\n    viewConfig.showDayAddButton ? state.drag !== null || state.slotDraft !== null : false,\n  );\n  const inDraft = useEventCalendarSelector<unknown, { isStart: boolean; isEnd: boolean } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft?.allDay) return null;\n      if (draft.start >= dayEnd || draft.end <= dayStart) return null;\n      return {\n        isStart: draft.start >= dayStart,\n        isEnd: draft.end <= dayEnd,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b || (a !== null && b !== null && a.isStart === b.isStart && a.isEnd === b.isEnd),\n    },\n  );\n\n  // Month drag-create selects whole DAYS, so the readout is a date range rather\n  // than a time range. Held separately from `inDraft` above, which only knows\n  // whether this cell is an edge of the selection, not what the selection is.\n  const draftRange = useEventCalendarSelector<unknown, { start: Date; end: Date } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft?.allDay) return null;\n      return { start: draft.start, end: draft.end };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.start.getTime() === b.start.getTime() &&\n          a.end.getTime() === b.end.getTime()),\n    },\n  );\n  // A single-day timed MOVE landing on THIS day: expose the proposed\n  // minute-of-day (+ color/validity) so the cell can render a drop placeholder\n  // at the correct time-sorted position, instead of the overlay marking a bar\n  // over the first chip. Skipped for bars (they keep the overlay ghost) and for\n  // a no-op move back onto the event's own day (the dimmed source already marks\n  // the spot).\n  const inlineDrop = useEventCalendarSelector<\n    unknown,\n    { min: number; valid: boolean; color?: string } | null\n  >(\n    (state) => {\n      const drag = state.drag;\n      if (drag?.kind !== \"move\" || drag.proposedAllDay) return null;\n      if (drag.proposedEnd.getTime() - drag.proposedStart.getTime() > 86400000) return null;\n      const dropDayMs = zonedStartOfDay(drag.proposedStart, settings.timeZone).getTime();\n      if (dropDayMs !== dayStart.getTime()) return null;\n      if (\n        zonedStartOfDay(drag.occurrence.start, settings.timeZone).getTime() === dayStart.getTime()\n      )\n        return null;\n      return {\n        min: (drag.proposedStart.getTime() - dropDayMs) / 60000,\n        valid: drag.valid,\n        color: drag.occurrence.event.color,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null && b !== null && a.min === b.min && a.valid === b.valid && a.color === b.color),\n    },\n  );\n\n  // Bars (allDay/multi-day) are drawn by the week-row overlay; the cell renders\n  // only single-day timed events, below the reserved bar lanes.\n  const extraHidden = hiddenBarKeys.size;\n  const hiddenBarSegs = segments.allDay.filter((s) => hiddenBarKeys.has(s.occurrence.key));\n  const m = segments.timed.length;\n  const timedSlots = Math.max(0, cap - reservedLanes);\n\n  // Static (at-rest) split: what the cell shows with no drag, and - crucially -\n  // what the \"+N more\" popover lists. The popover carries ONLY the hidden events\n  // (hidden bars + timed past the cap), never the chips already visible in the\n  // cell, so it never duplicates them. autoFit gives up one timed row to the\n  // \"+N more\" indicator so the visible chips fit the clipped cell height.\n  const staticOverflow = extraHidden > 0 || m > timedSlots;\n  const staticShown = autoFit && staticOverflow ? Math.max(0, timedSlots - 1) : timedSlots;\n  const overflowSegments = [...hiddenBarSegs, ...segments.timed.slice(staticShown)];\n\n  // Live split: while a timed chip is dragged onto this day, render the day\n  // exactly as it will look AFTER the drop. The dragged chip is a phantom in the\n  // time-sorted order and is shown as the placeholder - inline where it lands,\n  // or ON the \"+N more\" indicator when it lands in the overflow bucket (so the\n  // user sees the drop will push it into \"more\"). The \"+N more\" count always\n  // reflects the post-drop hidden total.\n  let visibleTimed: EventCalendarSegment[];\n  let overflowCount: number;\n  let placeholderIndex: number;\n  let placeholderAtMore: boolean;\n  if (!inlineDrop) {\n    visibleTimed = segments.timed.slice(0, staticShown);\n    overflowCount = overflowSegments.length;\n    placeholderIndex = -1;\n    placeholderAtMore = false;\n  } else {\n    // rank of the dragged chip in the resulting time-sorted list (chips are\n    // time-ordered, so this is the count starting at or before its time)\n    const insertRank = segments.timed.filter((s) => (s.startMin ?? 0) <= inlineDrop.min).length;\n    const dropOverflow = extraHidden > 0 || m + 1 > timedSlots;\n    // rows for timed items INCLUDING the phantom, before the \"+N more\" row\n    const vis = !dropOverflow ? m + 1 : autoFit ? Math.max(0, timedSlots - 1) : timedSlots;\n    placeholderAtMore = insertRank >= vis;\n    visibleTimed = placeholderAtMore\n      ? segments.timed.slice(0, vis)\n      : segments.timed.slice(0, Math.max(0, vis - 1));\n    placeholderIndex = placeholderAtMore ? -1 : insertRank;\n    overflowCount = extraHidden + (m - visibleTimed.length) + (placeholderAtMore ? 1 : 0);\n  }\n\n  // No dep array: the replacement for a chip that lost focus to a commit can\n  // appear on any re-render of any cell, and a cell with nothing to restore\n  // bails after two comparisons.\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  useIsoLayoutEffect(() => {\n    restoreChipFocus(rootRef.current, visibleTimed);\n  });\n\n  // Faint dashed drop placeholder, tinted to the dragged event's color, echoing\n  // the move ghost (EVENT_CALENDAR_GHOST.move); one chip-height tall so chips\n  // shift by exactly one row when it is inserted.\n  const dropPlaceholder = inlineDrop ? (\n    <div\n      key=\"ec-drop-placeholder\"\n      aria-hidden\n      data-slot=\"event-calendar-drop-placeholder\"\n      data-drop-invalid={!inlineDrop.valid || undefined}\n      className={cn(\n        \"shrink-0 rounded-sm border border-dashed\",\n        inlineDrop.valid\n          ? \"border-(--ec-event-color)/50 bg-(--ec-event-color)/8\"\n          : \"border-destructive/70 bg-destructive/10\",\n        viewConfig.classNames?.dragGhost,\n      )}\n      style={\n        {\n          \"--ec-event-color\": inlineDrop.color ?? \"var(--primary)\",\n          height: \"calc(var(--ec-month-bar-h, 1.75rem) - 0.125rem)\",\n        } as CSSProperties\n      }\n    />\n  ) : null;\n\n  const defaultContent = (\n    <>\n      <div\n        ref={contentRef}\n        className={cn(\n          // gap-0.5 pairs with the 0.125rem subtraction in the lane spacer\n          // below; changing the gap requires renderMonthCell\n          // px-1 matches the all-day bar wrapper inset so single-day chips and\n          // multi-day bars line up on the same left/right edge in a cell\n          \"flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden px-1 pt-1.5\",\n          viewConfig.classNames?.monthCellContent,\n        )}\n      >\n        {reservedLanes > 0 && (\n          <div\n            aria-hidden\n            className=\"shrink-0\"\n            style={{\n              // lane height already carries the 2px inter-lane gap; subtract\n              // it so spacer + the column's own gap-0.5 = the SAME 2px rhythm\n              // between the last bar and the first timed chip\n              height: `calc(${reservedLanes} * var(--ec-month-bar-h, 1.75rem) - 0.125rem)`,\n            }}\n          />\n        )}\n        {visibleTimed.flatMap((segment, i) => {\n          const chip = (\n            <EventCalendarEvent\n              key={segment.occurrence.key}\n              segment={segment}\n              // Remember the chip holding focus so a commit that re-keys it can\n              // hand focus back to the remounted one (see restoreChipFocus)\n              onFocus={(e) => {\n                focusedChip = {\n                  node: e.currentTarget,\n                  eventId: segment.occurrence.eventId,\n                  recurrenceIndex: segment.occurrence.recurrenceIndex,\n                };\n              }}\n              // A chip still in the tree lost focus on its own, so there is\n              // nothing to restore; only a blur from the remount is kept.\n              onBlur={(e) => {\n                if (e.currentTarget.isConnected) focusedChip = null;\n              }}\n              // Hold a fixed height like the all-day lane above; without this\n              // the chip flex-shrinks to whatever room the cell has left, so\n              // cells with a reserved bar lane or a second chip render shorter\n              // chips.\n              className=\"shrink-0\"\n            />\n          );\n          return i === placeholderIndex ? [dropPlaceholder, chip] : [chip];\n        })}\n        {placeholderIndex >= 0 && placeholderIndex >= visibleTimed.length && dropPlaceholder}\n        {overflowCount > 0 && (\n          <EventCalendarMoreIndicator\n            day={day}\n            count={overflowCount}\n            segments={overflowSegments}\n            dropInto={\n              placeholderAtMore && inlineDrop\n                ? { color: inlineDrop.color, valid: inlineDrop.valid }\n                : undefined\n            }\n          />\n        )}\n      </div>\n      {/* Day number + add affordance, bottom-right (Notion-style) */}\n      <div\n        className={cn(\n          \"flex items-center justify-end gap-1 px-2 pb-1.5\",\n          viewConfig.classNames?.monthCellFooter,\n        )}\n      >\n        {viewConfig.showDayAddButton && !isInteracting && (\n          <EventCalendarDayAddButton day={day} view=\"month\" />\n        )}\n        <span\n          data-slot=\"event-calendar-month-day-number\"\n          className={cn(\n            \"flex size-5 items-center justify-center rounded-full\",\n            isOutside && \"text-muted-foreground\",\n            // the filled circle already marks today; keep the number the same\n            // weight/size as the other days so it does not read as larger\n            // Same font-size as every other day; a lighter weight cancels the\n            // way white digits on the filled circle read bolder/larger than the\n            // dark-on-light numbers around them.\n            isToday && \"bg-primary text-primary-foreground font-light\",\n            viewConfig.classNames?.monthDayNumber,\n          )}\n        >\n          {format(toZoned(day, settings.timeZone), settings.i18n.formats.monthCellDay, {\n            locale: settings.locale,\n          })}\n        </span>\n      </div>\n    </>\n  );\n\n  const content =\n    viewConfig.renderMonthCell?.({\n      day,\n      segments,\n      isToday,\n      isOutside,\n      overflowCount,\n      defaultContent,\n    }) ?? defaultContent;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"gridcell\"\n      data-slot=\"event-calendar-month-cell\"\n      data-today={isToday || undefined}\n      data-outside={isOutside || undefined}\n      data-weekend={\n        settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()) || undefined\n      }\n      data-ec-day={dayStart.getTime()}\n      data-drop-target={isDropTarget ?? undefined}\n      data-off={isOff || undefined}\n      data-draft={inDraft ? \"\" : undefined}\n      aria-label={format(\n        toZoned(day, settings.timeZone),\n        settings.i18n.formats.monthCellAriaLabel,\n        { locale: settings.locale },\n      )}\n      className={cn(\n        \"group/ec-cell relative flex min-h-0 min-w-0 flex-col overflow-hidden\",\n        !isLast && \"border-e\",\n        isOutside && !settings.showOutsideDays && \"invisible\",\n        isOff && offClassName,\n        isToday &&\n          cn(\"bg-primary/3 border-b-primary/40 relative border-b-2\", viewConfig.todayClassName),\n        viewConfig.dayClassName?.(day),\n        // No drop-target bg fill on move/resize - the dragged bar + not-allowed\n        // cursor carry the feedback; a cell-wide color wash is too distracting.\n        // data-drop-target stays as an opt-in styling hook.\n        inDraft && EVENT_CALENDAR_SLOT_DRAFT.surface,\n        viewConfig.classNames?.monthCell,\n      )}\n      onPointerDown={(e) => {\n        const target = e.target as HTMLElement;\n        if (target.closest(\"[data-slot=event-calendar-event]\")) return;\n        if (target.closest(\"[data-slot=event-calendar-more]\")) return;\n        gestures.beginCreate(e, day, true);\n      }}\n      onClick={(e) => {\n        if (wasRecentDrag() || wasRecentChipPress()) return;\n        settings.onSlotClick?.({ date: day, allDay: true, view: \"month\" }, e);\n      }}\n    >\n      {inDraft && (\n        <span\n          aria-hidden\n          data-slot=\"event-calendar-slot-draft\"\n          className={cn(\n            \"pointer-events-none absolute inset-0 z-10\",\n            EVENT_CALENDAR_SLOT_DRAFT.segment,\n            inDraft.isStart && EVENT_CALENDAR_SLOT_DRAFT.segmentStart,\n            inDraft.isEnd && EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,\n            viewConfig.classNames?.slotDraft,\n          )}\n        >\n          {/* Date-range readout, the month-view counterpart of the time-grid\n              one. Rendered only on the FIRST cell of the selection, otherwise\n              a three-day drag would repeat the same label three times. Pinned\n              to the bottom because the day number owns the top of the cell,\n              and given its own surface so it stays legible over event chips. */}\n          {inDraft.isStart && draftRange && (\n            <span className={cn(\"absolute inset-x-1 bottom-1\", EVENT_CALENDAR_SLOT_DRAFT.label)}>\n              {settings.i18n.functions.formatDayRange(\n                {\n                  start: toZoned(draftRange.start, settings.timeZone),\n                  end: toZoned(draftRange.end, settings.timeZone),\n                },\n                { locale: settings.locale },\n              )}\n            </span>\n          )}\n        </span>\n      )}\n      {content}\n    </div>\n  );\n}\n\ninterface EventCalendarMoreIndicatorProps {\n  day: Date;\n  count: number;\n  /** The OVERFLOW (hidden) segments for this day - bars first, then timed. The\n   *  popover lists only these, never the chips already visible in the cell. */\n  segments: EventCalendarSegment[];\n  /** Set while a timed chip is dragged and will land in THIS overflow bucket:\n   *  the indicator itself becomes the drop placeholder (dashed, event-tinted) so\n   *  it reads as \"the chip joins the +N more list\". */\n  dropInto?: { color?: string; valid: boolean };\n}\n\n/**\n * \"+N more\" trigger opening a popover with the day's full event list.\n * onMoreClick returning false suppresses the built-in popover.\n */\nfunction EventCalendarMoreIndicator({\n  day,\n  count,\n  segments,\n  dropInto,\n}: EventCalendarMoreIndicatorProps) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const [open, setOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const headerId = useId();\n\n  // Grabbing a chip from this list starts a drag; close the popover so it does\n  // not sit over the drop target while the event is carried to another day.\n  const isDragging = useEventCalendarSelector<unknown, boolean>((state) => state.drag !== null);\n  useEffect(() => {\n    if (isDragging) setOpen(false);\n  }, [isDragging]);\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        data-slot=\"event-calendar-more\"\n        data-drop-into={dropInto ? \"\" : undefined}\n        data-drop-invalid={dropInto && !dropInto.valid ? \"\" : undefined}\n        className={cn(\n          \"text-muted-foreground hover:text-foreground cursor-pointer truncate rounded-sm px-1.5 text-start outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n          // While a dragged chip will land in this overflow bucket, the \"+N more\"\n          // link itself becomes the drop placeholder: dashed, event-tinted, one\n          // chip tall, so the drop target is unmistakable.\n          dropInto &&\n            cn(\n              \"flex shrink-0 items-center border border-dashed\",\n              dropInto.valid\n                ? \"text-foreground border-(--ec-event-color)/50 bg-(--ec-event-color)/8\"\n                : \"border-destructive/70 bg-destructive/10 text-destructive\",\n            ),\n          viewConfig.classNames?.moreIndicator,\n        )}\n        style={\n          dropInto\n            ? ({\n                \"--ec-event-color\": dropInto.color ?? \"var(--primary)\",\n                height: \"calc(var(--ec-month-bar-h, 1.75rem) - 0.125rem)\",\n              } as CSSProperties)\n            : undefined\n        }\n        onClick={(e: React.MouseEvent) => {\n          // a native click (not a react-aria press) so the cell underneath\n          // never sees it and starts a slot selection\n          e.stopPropagation();\n          const verdict = settings.onMoreClick?.(\n            day,\n            segments.map((segment) => segment.occurrence),\n            e,\n          );\n          setOpen(verdict === false ? false : !open);\n        }}\n      >\n        {viewConfig.renderMoreIndicator?.({ day, count, segments }) ??\n          settings.i18n.labels.more(count)}\n      </button>\n      <Popover\n        data-slot=\"event-calendar-more-popover\"\n        triggerRef={triggerRef}\n        isOpen={open}\n        onOpenChange={setOpen}\n        placement={\n          viewConfig.morePopoverAlign === \"center\"\n            ? \"bottom\"\n            : `bottom ${viewConfig.morePopoverAlign}`\n        }\n        // text-xs re-establishes the calendar's base type here because this\n        // content is portaled out of the root subtree and cannot inherit it.\n        className={cn(\"w-64 gap-1! p-2! text-xs\", viewConfig.classNames?.morePopover)}\n      >\n        {/* The popover is a dialog, so it needs a name. The built-in body\n            renders the day header this list belongs to, so point at that; a\n            consumer body has no header and gets the formatted day instead. */}\n        <Dialog\n          aria-labelledby={viewConfig.renderMoreContent ? undefined : headerId}\n          aria-label={\n            viewConfig.renderMoreContent\n              ? format(toZoned(day, settings.timeZone), settings.i18n.formats.moreDayHeader, {\n                  locale: settings.locale,\n                })\n              : undefined\n          }\n          className=\"flex flex-col gap-1 outline-none\"\n        >\n          {/* Portaled, but React events still bubble to the month cell:\n              keep clicks inside from creating slot selections there. */}\n          {/* biome-ignore lint/a11y/noStaticElementInteractions: event barrier, not a control */}\n          <div className=\"contents\" onClick={(e) => e.stopPropagation()}>\n            {viewConfig.renderMoreContent ? (\n              viewConfig.renderMoreContent({\n                day,\n                segments,\n                close: () => setOpen(false),\n              })\n            ) : (\n              <EventCalendarMoreDefaultContent day={day} segments={segments} headerId={headerId} />\n            )}\n          </div>\n        </Dialog>\n      </Popover>\n    </>\n  );\n}\n\n/** Built-in \"+N more\" popover body: day header + the day's chips. */\nfunction EventCalendarMoreDefaultContent({\n  day,\n  segments,\n  headerId,\n}: {\n  day: Date;\n  segments: EventCalendarSegment[];\n  /** Names the popover dialog: the header IS the list's accessible name. */\n  headerId?: string;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  return (\n    <>\n      <div\n        id={headerId}\n        className={cn(\n          \"text-muted-foreground px-1 py-1 text-xs font-medium\",\n          viewConfig.classNames?.morePopoverHeader,\n        )}\n      >\n        {format(toZoned(day, settings.timeZone), settings.i18n.formats.moreDayHeader, {\n          locale: settings.locale,\n        })}\n      </div>\n      {/* The scroll region breaks out of the popover's right padding (-me-2)\n            so the scrollbar sits flush in the gutter; the list then pads itself\n            back (ps-1 aligns with the header, pe-4 clears the ~10px bar with a\n            gap) and adds py-1 so the first/last focus ring is not clipped by\n            the overflow. Layout is identical with or without a scrollbar. */}\n      {viewConfig.scrollbars === \"native\" ? (\n        <div\n          data-ec-native-scroll=\"\"\n          className=\"-me-2 max-h-(--ec-more-max-height,16rem) min-h-0 overflow-y-auto\"\n        >\n          <div className=\"flex flex-col gap-1 py-1 ps-1 pe-4\">\n            {segments.map((segment) => (\n              <EventCalendarEvent\n                key={segment.occurrence.key}\n                segment={segment}\n                className=\"py-0.5\"\n              />\n            ))}\n          </div>\n        </div>\n      ) : (\n        <ScrollArea className=\"-me-2 max-h-(--ec-more-max-height,16rem) min-h-0\">\n          <div className=\"flex flex-col gap-1 py-1 ps-1 pe-4\">\n            {segments.map((segment) => (\n              <EventCalendarEvent\n                key={segment.occurrence.key}\n                segment={segment}\n                className=\"py-0.5\"\n              />\n            ))}\n          </div>\n        </ScrollArea>\n      )}\n    </>\n  );\n}\n\nexport type { EventCalendarMonthViewProps, EventCalendarMoreIndicatorProps };\nexport { EventCalendarMonthView, EventCalendarMoreIndicator };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-month-view.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-nav.tsx",
      "content": "// Title: Event Calendar Nav\n// Description: Composable navigation - Today, prev/next, period title, view switcher dropdown, and a free toolbar slot.\n\n\"use client\";\n\nimport { fromDate, toCalendarDate } from \"@internationalized/date\";\nimport { cn } from \"cn\";\nimport { CalendarIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { type ReactNode, useState } from \"react\";\nimport { Dialog, DialogTrigger, I18nProvider, type Key } from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { Calendar } from \"@/core/calendar/calendar\";\nimport {\n  DropdownMenu,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/core/dropdown-menu/dropdown-menu\";\nimport { Popover } from \"@/core/popover/popover\";\nimport { Tooltip, TooltipTrigger } from \"@/core/tooltip/tooltip\";\nimport { format } from \"@/lib/date-fns-compat/index\";\nimport {\n  useEventCalendarNavigation,\n  useEventCalendarSettings,\n  useEventCalendarView,\n  useEventCalendarViewConfig,\n} from \"./event-calendar\";\nimport { toZoned } from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type { CalendarView } from \"./event-calendar-types\";\n\n/** Configured nav button variant/size (viewConfig.navButtonVariant/Size)\n *  plus the shared classNames.navButton hook, merged on every nav button. */\nfunction useNavButtonProps(): {\n  variant: \"ghost\" | \"outline\" | \"secondary\" | \"default\";\n  size: \"sm\" | \"default\";\n  iconSize: \"icon-sm\" | \"icon\";\n  className: string | undefined;\n} {\n  const viewConfig = useEventCalendarViewConfig();\n  return {\n    variant: viewConfig.navButtonVariant,\n    size: viewConfig.navButtonSize,\n    iconSize: viewConfig.navButtonSize === \"sm\" ? \"icon-sm\" : \"icon\",\n    className: viewConfig.classNames?.navButton,\n  };\n}\n\n/** Resolved nav tooltip policy (viewConfig.navTooltips + classNames.navTooltip). */\nfunction useNavTooltipConfig(): {\n  disabled: boolean;\n  side: \"top\" | \"bottom\" | \"left\" | \"right\";\n  delay: number;\n  closeDelay: number;\n  timeout: number;\n  className: string | undefined;\n} {\n  const viewConfig = useEventCalendarViewConfig();\n  const config = viewConfig.navTooltips === false ? undefined : viewConfig.navTooltips;\n  return {\n    disabled: viewConfig.navTooltips === false,\n    // the nav sits at the top of the calendar, so tooltips open upward by\n    // default (away from the grid); collision flipping still drops them below\n    // when there is no room above\n    side: config?.side ?? \"top\",\n    delay: config?.delay ?? 600,\n    closeDelay: config?.closeDelay ?? 0,\n    timeout: config?.timeout ?? 300,\n    className: viewConfig.classNames?.navTooltip,\n  };\n}\n\ntype NavButtonProps = Omit<React.ComponentProps<typeof Button>, \"children\"> & {\n  children?: ReactNode;\n  /**\n   * Tooltip policy (the part that usually goes wrong on clickable elements):\n   * tooltips appear ONLY on hover or keyboard focus-visible - a pointer click\n   * never re-triggers them. Buttons that open overlays (the view switcher)\n   * use a hover-only tooltip that is force-closed while the overlay is up and\n   * ignores focus, so nothing flashes when focus returns after selection.\n   * Icon-only buttons default to their accessible label; Today defaults to\n   * the actual current date (info the label doesn't carry). Pass null to\n   * disable one, or any node to override; viewConfig.navTooltips=false turns\n   * them all off (its object form tunes side/delay/closeDelay/timeout).\n   */\n  tooltip?: ReactNode | null;\n};\n\n/** Hover/focus-visible tooltip wrapper; renders the bare button when disabled\n *  (per-button content=null or viewConfig.navTooltips=false). react-aria\n *  tooltips never open on press or on focus returning from an overlay. */\nfunction NavTooltip({\n  content,\n  children,\n}: {\n  content: ReactNode | null;\n  children: React.ReactElement;\n}) {\n  const tooltips = useNavTooltipConfig();\n  if (tooltips.disabled || content === null || content === undefined) return children;\n  return (\n    <TooltipTrigger delay={tooltips.delay} closeDelay={tooltips.closeDelay}>\n      {children}\n      <Tooltip placement={tooltips.side} className={tooltips.className}>\n        {content}\n      </Tooltip>\n    </TooltipTrigger>\n  );\n}\n\nfunction EventCalendarNavToday({ className, children, tooltip, ...props }: NavButtonProps) {\n  const { today, isToday } = useEventCalendarNavigation();\n  const settings = useEventCalendarSettings();\n  const nav = useNavButtonProps();\n  // display-zone \"today\", like every other today derivation in the calendar\n  // (a system-zone new Date() can name a different day than Today opens)\n  const defaultTooltip = format(\n    toZoned(new Date(), settings.timeZone),\n    settings.i18n.formats.dayTitle,\n    { locale: settings.locale },\n  );\n  return (\n    <NavTooltip content={tooltip === undefined ? defaultTooltip : tooltip}>\n      <Button\n        variant={nav.variant}\n        size={nav.size}\n        data-slot=\"event-calendar-nav-today\"\n        data-active={isToday || undefined}\n        className={cn(nav.className, className)}\n        onPress={today}\n        {...props}\n      >\n        {children ?? settings.i18n.labels.today}\n      </Button>\n    </NavTooltip>\n  );\n}\n\nfunction EventCalendarNavPrev({ className, children, tooltip, ...props }: NavButtonProps) {\n  const { prev } = useEventCalendarNavigation();\n  const settings = useEventCalendarSettings();\n  const nav = useNavButtonProps();\n  return (\n    <NavTooltip content={tooltip === undefined ? settings.i18n.labels.previous : tooltip}>\n      <Button\n        variant={nav.variant}\n        size={nav.iconSize}\n        data-slot=\"event-calendar-nav-prev\"\n        aria-label={settings.i18n.labels.previous}\n        className={cn(nav.className, className)}\n        onPress={prev}\n        {...props}\n      >\n        {children ?? <ChevronLeftIcon className=\"size-4\" aria-hidden=\"true\" />}\n      </Button>\n    </NavTooltip>\n  );\n}\n\nfunction EventCalendarNavNext({ className, children, tooltip, ...props }: NavButtonProps) {\n  const { next } = useEventCalendarNavigation();\n  const settings = useEventCalendarSettings();\n  const nav = useNavButtonProps();\n  return (\n    <NavTooltip content={tooltip === undefined ? settings.i18n.labels.next : tooltip}>\n      <Button\n        variant={nav.variant}\n        size={nav.iconSize}\n        data-slot=\"event-calendar-nav-next\"\n        aria-label={settings.i18n.labels.next}\n        className={cn(nav.className, className)}\n        onPress={next}\n        {...props}\n      >\n        {children ?? <ChevronRightIcon className=\"size-4\" aria-hidden=\"true\" />}\n      </Button>\n    </NavTooltip>\n  );\n}\n\ninterface EventCalendarTitleProps extends React.ComponentProps<\"div\"> {\n  format?: (ctx: { title: string }) => ReactNode;\n}\n\nfunction EventCalendarTitle({ className, format: formatTitle, ...props }: EventCalendarTitleProps) {\n  const { title } = useEventCalendarNavigation();\n  const viewConfig = useEventCalendarViewConfig();\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-title\",\n    \"aria-live\": \"polite\" as const,\n    className: cn(\n      \"min-w-0 truncate text-sm font-semibold\",\n      viewConfig.classNames?.title,\n      className,\n    ),\n    children: formatTitle?.({ title }) ?? title,\n  };\n  return <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />;\n}\n\ninterface EventCalendarViewSwitcherProps\n  extends Omit<React.ComponentProps<typeof Button>, \"children\"> {\n  children?: ReactNode;\n  /** Hover/focus-visible hint; defaults to the \"Select view\" label. Pass\n   *  null to disable. */\n  tooltip?: ReactNode | null;\n}\n\nfunction EventCalendarViewSwitcher({\n  className,\n  children,\n  tooltip,\n  ...props\n}: EventCalendarViewSwitcherProps) {\n  const { view, dayCount, availableViews, setView } = useEventCalendarView();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const nav = useNavButtonProps();\n  const labels = settings.i18n.labels;\n  // Controlled open: selecting a view swaps the whole content subtree in the\n  // same press, so closing must not depend on the menu's internal handler.\n  const [open, setOpen] = useState(false);\n\n  const viewLabel = (v: CalendarView) =>\n    v === \"days\" ? settings.i18n.viewNames.days(dayCount) : settings.i18n.viewNames[v];\n  const activeKey = view === \"days\" ? `days-${dayCount}` : view;\n\n  const onAction = (key: Key) => {\n    setOpen(false);\n    const id = String(key);\n    if (id.startsWith(\"days-\")) setView(\"days\", { dayCount: Number(id.slice(5)) });\n    else setView(id as CalendarView);\n  };\n\n  return (\n    <DropdownMenuTrigger isOpen={open} onOpenChange={setOpen}>\n      <NavTooltip content={tooltip === undefined ? labels.selectView : tooltip}>\n        <Button\n          variant={nav.variant}\n          size={nav.size}\n          data-slot=\"event-calendar-view-switcher\"\n          aria-label={labels.selectView}\n          className={cn(\"gap-1\", nav.className, className)}\n          {...props}\n        >\n          {children ?? (\n            <>\n              {viewLabel(view)}\n              <ChevronDownIcon className=\"size-4 opacity-60\" aria-hidden=\"true\" />\n            </>\n          )}\n        </Button>\n      </NavTooltip>\n      <DropdownMenu\n        selectionMode=\"single\"\n        selectedKeys={[activeKey]}\n        onAction={onAction}\n        className={cn(\"min-w-44\", viewConfig.classNames?.viewSwitcherContent)}\n      >\n        <DropdownMenuGroup>\n          <DropdownMenuLabel\n            className={cn(\n              \"font-normal text-muted-foreground\",\n              viewConfig.classNames?.viewSwitcherLabel,\n            )}\n          >\n            {labels.selectView}\n          </DropdownMenuLabel>\n          {availableViews.flatMap((v) =>\n            v === \"days\"\n              ? viewConfig.dayCountPresets.map((count) => (\n                  <DropdownMenuItem\n                    key={`days-${count}`}\n                    id={`days-${count}`}\n                    textValue={settings.i18n.viewNames.days(count)}\n                  >\n                    {settings.i18n.viewNames.days(count)}\n                    {/* hint derived from the preset itself, not i18n's default */}\n                    {viewConfig.enableShortcuts && (\n                      <EventCalendarViewShortcut>{count}</EventCalendarViewShortcut>\n                    )}\n                  </DropdownMenuItem>\n                ))\n              : [\n                  <DropdownMenuItem key={v} id={v} textValue={viewLabel(v)}>\n                    {viewLabel(v)}\n                    {viewConfig.enableShortcuts && (\n                      <EventCalendarViewShortcut>\n                        {labels.viewShortcuts[v]}\n                      </EventCalendarViewShortcut>\n                    )}\n                  </DropdownMenuItem>,\n                ],\n          )}\n        </DropdownMenuGroup>\n      </DropdownMenu>\n    </DropdownMenuTrigger>\n  );\n}\n\n/** Outline key badge; theme-token only, so it adapts to every style. */\nfunction EventCalendarViewShortcut({ children }: { children: ReactNode }) {\n  const viewConfig = useEventCalendarViewConfig();\n  return (\n    <kbd\n      data-slot=\"event-calendar-view-shortcut\"\n      className={cn(\n        \"text-muted-foreground ms-auto inline-flex size-5 shrink-0 items-center justify-center rounded-sm border font-sans text-xs\",\n        viewConfig.classNames?.viewShortcut,\n      )}\n    >\n      {children}\n    </kbd>\n  );\n}\n\ninterface EventCalendarDatePickerProps\n  extends Omit<React.ComponentProps<typeof Button>, \"children\"> {\n  children?: ReactNode;\n  /** Hover/focus-visible hint; defaults to null - no tooltip, because the\n   *  button opens an overlay (see the NavButtonProps tooltip policy). */\n  tooltip?: ReactNode | null;\n}\n\n/**\n * Optional go-to-date picker: the registry's react-aria `Calendar` in a\n * popover. Not part of the default nav - compose it yourself (or any external\n * picker driving useEventCalendarNavigation().goTo). Picking a date re-anchors\n * navigation; the visible range isn't highlighted in week/N-days/agenda views.\n */\nfunction EventCalendarDatePicker({\n  className,\n  children,\n  tooltip = null,\n  ...props\n}: EventCalendarDatePickerProps) {\n  const { date, goTo } = useEventCalendarNavigation();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const nav = useNavButtonProps();\n  const [open, setOpen] = useState(false);\n\n  return (\n    <DialogTrigger isOpen={open} onOpenChange={setOpen}>\n      <NavTooltip content={tooltip}>\n        <Button\n          variant={nav.variant}\n          size={nav.iconSize}\n          data-slot=\"event-calendar-date-picker\"\n          aria-label={settings.i18n.labels.goToDate}\n          className={cn(nav.className, className)}\n          {...props}\n        >\n          {children ?? <CalendarIcon className=\"size-4\" aria-hidden=\"true\" />}\n        </Button>\n      </NavTooltip>\n      <Popover\n        placement=\"bottom start\"\n        className={cn(\"w-auto p-0!\", viewConfig.classNames?.datePickerContent)}\n      >\n        <Dialog aria-label={settings.i18n.labels.goToDate} className=\"outline-none\">\n          <I18nProvider locale={settings.locale}>\n            <Calendar\n              aria-label={settings.i18n.labels.goToDate}\n              value={toCalendarDate(fromDate(date, settings.timeZone))}\n              onChange={(picked) => {\n                goTo(picked.toDate(settings.timeZone));\n                setOpen(false);\n              }}\n            />\n          </I18nProvider>\n        </Dialog>\n      </Popover>\n    </DialogTrigger>\n  );\n}\n\ntype EventCalendarToolbarProps = React.ComponentProps<\"div\">;\n\n/** Free slot for consumer toolbar buttons; pure layout shell. */\nfunction EventCalendarToolbar({ className, ...props }: EventCalendarToolbarProps) {\n  const viewConfig = useEventCalendarViewConfig();\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-toolbar\",\n    className: cn(\"flex items-center gap-2\", viewConfig.classNames?.toolbar, className),\n    children: props.children,\n  };\n  return <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />;\n}\n\ninterface EventCalendarNavProps extends React.ComponentProps<\"div\"> {\n  /**\n   * Render the view switcher in the composed layout. Turn off when the\n   * calendar ships with a fixed view (e.g. a month-only embed) and users\n   * should not be able to change it.\n   * @default true\n   */\n  showViewSwitcher?: boolean;\n}\n\n/**\n * Default composed nav: Today, prev/next, title, spacer, view switcher.\n * Pass children to use it as a pure layout shell instead.\n */\nfunction EventCalendarNav({\n  className,\n  children,\n  showViewSwitcher = true,\n  ...props\n}: EventCalendarNavProps) {\n  const viewConfig = useEventCalendarViewConfig();\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-nav\",\n    className: cn(\n      \"flex min-w-0 flex-wrap items-center gap-1 px-2 py-2\",\n      viewConfig.stickyNav && \"bg-background sticky top-0 z-30\",\n      viewConfig.classNames?.nav,\n      className,\n    ),\n    children: children ?? (\n      // react-aria warms tooltips up globally: after the first one, moving\n      // between buttons shows the next instantly.\n      <>\n        <EventCalendarNavToday />\n        {showViewSwitcher && <EventCalendarViewSwitcher />}\n        <div className=\"flex items-center\">\n          <EventCalendarNavPrev />\n          <EventCalendarNavNext />\n        </div>\n        {/* ms-3 sets the title apart from the tight control cluster so the\n            period reads as its own group, not another button */}\n        <EventCalendarTitle className=\"ms-3\" />\n        <div className=\"grow\" />\n      </>\n    ),\n  };\n  return <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />;\n}\n\nexport type {\n  EventCalendarNavProps,\n  EventCalendarTitleProps,\n  EventCalendarToolbarProps,\n  EventCalendarViewSwitcherProps,\n};\nexport {\n  EventCalendarDatePicker,\n  EventCalendarNav,\n  EventCalendarNavNext,\n  EventCalendarNavPrev,\n  EventCalendarNavToday,\n  EventCalendarTitle,\n  EventCalendarToolbar,\n  EventCalendarViewSwitcher,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-nav.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-recurrence.tsx",
      "content": "// Title: Event Calendar Recurrence\n// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.\n\nimport { addDays, addMonths, addWeeks, addYears, TZDate } from \"@/lib/date-fns-compat/index\";\nimport type {\n  CalendarEvent,\n  EventCalendarDateRange,\n  EventCalendarOccurrence,\n  EventCalendarRecurrenceRule,\n  EventCalendarWeekday,\n} from \"./event-calendar-types\";\n\n/** Guard: max window-intersecting occurrences per event per expansion. */\nconst MAX_OCCURRENCES = 1000;\n\n/** Runaway guard: absolute cap on period iterations regardless of visibility. */\nconst MAX_ITERATIONS = 10000;\n\n/** Gregorian mean period lengths for the O(1) fast-forward approximation. */\nconst PERIOD_MS: Record<EventCalendarRecurrenceRule[\"freq\"], number> = {\n  daily: 86400000,\n  weekly: 604800000,\n  monthly: 2629746000, // 365.2425 / 12 days\n  yearly: 31556952000, // 365.2425 days\n};\n\nconst WEEKDAYS: EventCalendarWeekday[] = [\"SU\", \"MO\", \"TU\", \"WE\", \"TH\", \"FR\", \"SA\"];\n\nclass EventCalendarRecurrenceError extends Error {\n  constructor(part: string) {\n    super(\n      `Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`,\n    );\n    this.name = \"EventCalendarRecurrenceError\";\n  }\n}\n\n/**\n * Parses a raw RRULE line (with or without the \"RRULE:\" prefix) into the\n * structured subset. Pass the display time zone so Z-less UNTIL values are\n * interpreted as wall time in that zone rather than the machine zone.\n */\nfunction parseRRuleString(input: string, timeZone?: string): EventCalendarRecurrenceRule {\n  const body = input.trim().replace(/^RRULE:/i, \"\");\n  const rule: Partial<EventCalendarRecurrenceRule> = {};\n\n  for (const pair of body.split(\";\")) {\n    if (!pair) continue;\n    const [rawKey, rawValue] = pair.split(\"=\");\n    const key = rawKey?.toUpperCase();\n    const value = rawValue ?? \"\";\n\n    switch (key) {\n      case \"FREQ\": {\n        const freq = value.toLowerCase();\n        if (freq !== \"daily\" && freq !== \"weekly\" && freq !== \"monthly\" && freq !== \"yearly\") {\n          throw new EventCalendarRecurrenceError(`FREQ=${value}`);\n        }\n        rule.freq = freq;\n        break;\n      }\n      case \"INTERVAL\":\n        rule.interval = Math.max(1, parseInt(value, 10) || 1);\n        break;\n      case \"COUNT\":\n        rule.count = Math.max(1, parseInt(value, 10) || 1);\n        break;\n      case \"UNTIL\":\n        rule.until = parseRRuleDate(value, timeZone);\n        break;\n      case \"BYDAY\":\n        rule.byWeekday = value.split(\",\").map((token) => {\n          // RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is\n          // already folded above - rejecting \"mo\" here would be inconsistent\n          const match = /^(-?\\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token.trim().toUpperCase());\n          if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`);\n          const day = match[2] as EventCalendarWeekday;\n          return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day;\n        });\n        break;\n      case \"BYMONTHDAY\":\n        rule.byMonthDay = value.split(\",\").map((v) => {\n          const day = parseInt(v, 10);\n          // NaN would survive parsing, match no day in any month and leave the\n          // event permanently invisible with no error anywhere\n          if (Number.isNaN(day)) {\n            throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`);\n          }\n          return day;\n        });\n        break;\n      case \"BYMONTH\":\n        rule.byMonth = value.split(\",\").map((v) => parseInt(v, 10));\n        break;\n      case \"WKST\": {\n        const day = value.trim().toUpperCase() as EventCalendarWeekday;\n        if (!WEEKDAYS.includes(day)) {\n          throw new EventCalendarRecurrenceError(`WKST=${value}`);\n        }\n        rule.weekStart = day;\n        break;\n      }\n      default:\n        throw new EventCalendarRecurrenceError(key ?? pair);\n    }\n  }\n\n  if (!rule.freq) throw new EventCalendarRecurrenceError(\"missing FREQ\");\n  return rule as EventCalendarRecurrenceRule;\n}\n\nfunction parseRRuleDate(value: string, timeZone?: string): Date {\n  // RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z\n  // designators are case-insensitive too (RFC 5545 3.1), so fold before matching.\n  const match = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})(Z)?)?$/.exec(\n    value.trim().toUpperCase(),\n  );\n  if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`);\n  const [, y, m, d, hh = \"23\", mm = \"59\", ss = \"59\", z] = match;\n  // Z-less values (including date-only ones, which mean end of that day\n  // inclusive) are wall time in the display zone, not the machine zone.\n  const date = z\n    ? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)\n    : timeZone\n      ? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())\n      : new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`);\n  if (Number.isNaN(date.getTime())) {\n    throw new EventCalendarRecurrenceError(`UNTIL=${value}`);\n  }\n  return date;\n}\n\n/** Serializes the structured subset back to an RRULE line (without prefix). */\nfunction formatRRuleString(rule: EventCalendarRecurrenceRule): string {\n  const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`];\n  if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`);\n  if (rule.count) parts.push(`COUNT=${rule.count}`);\n  if (rule.until) {\n    const u = rule.until;\n    const pad = (n: number) => String(n).padStart(2, \"0\");\n    parts.push(\n      `UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`,\n    );\n  }\n  if (rule.byWeekday?.length) {\n    parts.push(\n      `BYDAY=${rule.byWeekday\n        .map((d) => (typeof d === \"string\" ? d : `${d.ordinal}${d.day}`))\n        .join(\",\")}`,\n    );\n  }\n  if (rule.byMonthDay?.length) parts.push(`BYMONTHDAY=${rule.byMonthDay.join(\",\")}`);\n  if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(\",\")}`);\n  if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`);\n  return parts.join(\";\");\n}\n\nfunction resolveRule(\n  recurrence: EventCalendarRecurrenceRule | string,\n  timeZone?: string,\n): EventCalendarRecurrenceRule {\n  return typeof recurrence === \"string\" ? parseRRuleString(recurrence, timeZone) : recurrence;\n}\n\n/**\n * Expands one event into its occurrences intersecting the range.\n * Non-recurring events yield at most one occurrence. Recurrence iteration is\n * wall-time based in the display zone (DST-safe day/week/month steps), and a\n * span that is a whole number of local days keeps that day count across a DST\n * transition (timed spans keep their absolute length instead).\n *\n * exDates remove exactly-matching instants (after COUNT numbering,\n * Google-style: an exception still consumes its COUNT slot); rDates add extra\n * instants with the same duration. RECURRENCE-ID override replacement lives\n * in buildEventIndex, where the override event and its parent series meet.\n */\nfunction expandRecurrence<TData>(\n  event: CalendarEvent<TData>,\n  range: EventCalendarDateRange,\n  ctx: { timeZone: string },\n): EventCalendarOccurrence<TData>[] {\n  const allDay = event.allDay ?? false;\n\n  if (!event.recurrence) {\n    // The exclusive `end > start` test is right for anything with duration,\n    // but it also drops a zero-length milestone pinned to the first visible\n    // instant - which reads as an event that randomly disappears until you\n    // page one period back. A point occurrence only has to be inside.\n    const isPoint = event.end.getTime() === event.start.getTime();\n    if (\n      event.start < range.end &&\n      (event.end > range.start || (isPoint && event.start >= range.start))\n    ) {\n      return [\n        {\n          key: `${event.id}::${event.start.toISOString()}`,\n          eventId: event.id,\n          event,\n          start: event.start,\n          end: event.end,\n          allDay,\n          isRecurring: false,\n        },\n      ];\n    }\n    return [];\n  }\n\n  const rule = resolveRule(event.recurrence, ctx.timeZone);\n  const interval = Math.max(1, rule.interval ?? 1);\n  const durationMs = event.end.getTime() - event.start.getTime();\n  const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone);\n  // A span of whole local days is wall time, not an absolute delta: a 3 day\n  // all-day bar crossing spring forward would otherwise end at 01:00 and\n  // occupy a fourth day in the month grid. Timed spans stay absolute so a two\n  // hour meeting is still two hours.\n  const daySpan = Math.round(durationMs / 86400000);\n  const wallDaySpan =\n    daySpan > 0 && addDays(zonedStart, daySpan).getTime() === event.end.getTime() ? daySpan : null;\n  const endFor = (start: Date): Date =>\n    wallDaySpan === null\n      ? new Date(start.getTime() + durationMs)\n      : new Date(addDays(new TZDate(start.getTime(), ctx.timeZone), wallDaySpan).getTime());\n  // Excluded instants matched exactly; filtering happens at push time so an\n  // exception still consumes its COUNT slot (Google-style numbering).\n  const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()));\n\n  const weeklyDays: number[] | null =\n    rule.freq === \"weekly\" && rule.byWeekday?.length\n      ? [\n          ...new Set(\n            rule.byWeekday.map((d) => {\n              if (typeof d !== \"string\") {\n                throw new EventCalendarRecurrenceError(\"BYDAY ordinal outside monthly/yearly\");\n              }\n              return WEEKDAYS.indexOf(d);\n            }),\n          ),\n        ]\n      : null;\n\n  // BYDAY resolved inside a month: monthly, and yearly within each BYMONTH\n  // (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not \"the anchor's day\")\n  const monthlyByDay =\n    (rule.freq === \"monthly\" || rule.freq === \"yearly\") && rule.byWeekday?.length\n      ? rule.byWeekday\n      : null;\n\n  // RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides\n  // which selected days share a period, so an ignored WKST puts half of every\n  // biweekly series a week off. Default MO, per the RFC.\n  const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? \"MO\");\n  /** Days from the WKST-aligned week start to `day` (0-6). */\n  const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7;\n\n  // yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month\n  const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? [];\n  const yearlyMonths: number[] =\n    validByMonth.length > 0\n      ? [...new Set(validByMonth)].sort((a, b) => a - b)\n      : [zonedStart.getMonth() + 1];\n\n  // month-shaped BY* parts make the per-period occurrence count variable\n  const hasMonthDayParts =\n    (rule.freq === \"monthly\" && Boolean(rule.byMonthDay?.length || monthlyByDay)) ||\n    (rule.freq === \"yearly\" &&\n      Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay));\n\n  const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();\n\n  // RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the\n  // set instead of reshaping it. Dropping them silently expanded the series as\n  // if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering\n  // here rather than at push time keeps COUNT numbering RFC-correct: a\n  // candidate the filter removes is not an occurrence and consumes no slot.\n  const limitByMonth = rule.freq !== \"yearly\" && validByMonth.length > 0 ? validByMonth : null;\n  const limitByMonthDay = rule.freq === \"daily\" && rule.byMonthDay?.length ? rule.byMonthDay : null;\n  const limitByWeekday =\n    rule.freq === \"daily\" && rule.byWeekday?.length\n      ? rule.byWeekday.map((d) => WEEKDAYS.indexOf(typeof d === \"string\" ? d : d.day))\n      : null;\n  const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday);\n\n  const passesLimits = (candidate: TZDate): boolean => {\n    if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {\n      return false;\n    }\n    if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {\n      return false;\n    }\n    if (limitByMonthDay) {\n      const total = daysInMonth(candidate.getFullYear(), candidate.getMonth());\n      const day = candidate.getDate();\n      // negative BYMONTHDAY counts back from month end, as in monthDays\n      if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {\n        return false;\n      }\n    }\n    return true;\n  };\n\n  /** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */\n  const zonedDate = (year: number, month: number, day: number) =>\n    new TZDate(\n      year,\n      month,\n      day,\n      zonedStart.getHours(),\n      zonedStart.getMinutes(),\n      zonedStart.getSeconds(),\n      zonedStart.getMilliseconds(),\n      ctx.timeZone,\n    );\n\n  /** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */\n  const monthDays = (year: number, month: number): number[] => {\n    const total = daysInMonth(year, month);\n    let days: number[] | null = null;\n    if (rule.byMonthDay?.length) {\n      // negative BYMONTHDAY counts back from month end; nonexistent days skip\n      days = rule.byMonthDay\n        .map((n) => (n < 0 ? total + 1 + n : n))\n        .filter((n) => n >= 1 && n <= total);\n    }\n    if (monthlyByDay) {\n      const byDayMatches: number[] = [];\n      for (const entry of monthlyByDay) {\n        const day = typeof entry === \"string\" ? entry : entry.day;\n        const ordinal = typeof entry === \"string\" ? 0 : entry.ordinal;\n        const weekday = WEEKDAYS.indexOf(day);\n        const matches: number[] = [];\n        for (let d = 1; d <= total; d++) {\n          if (new Date(year, month, d).getDay() === weekday) matches.push(d);\n        }\n        if (ordinal === 0) {\n          byDayMatches.push(...matches); // plain BYDAY: every matching weekday\n        } else {\n          // 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip\n          const pick = ordinal > 0 ? matches[ordinal - 1] : matches[matches.length + ordinal];\n          if (pick !== undefined) byDayMatches.push(pick);\n        }\n      }\n      days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches;\n    }\n    if (!days) days = [Math.min(zonedStart.getDate(), total)]; // clamp to month end\n    return [...new Set(days)].sort((a, b) => a - b);\n  };\n\n  /** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */\n  const periodCandidates = (period: number): TZDate[] => {\n    if (rule.freq === \"daily\") return [addDays(zonedStart, period * interval)];\n    if (rule.freq === \"weekly\") {\n      const base = addWeeks(zonedStart, period * interval);\n      if (!weeklyDays) return [base];\n      const week: TZDate[] = [];\n      // walk the WKST-aligned week so candidates stay chronological\n      const baseOffset = fromWeekStart(base.getDay());\n      for (let offset = 0; offset < 7; offset++) {\n        const candidate = addDays(base, offset - baseOffset);\n        if (!weeklyDays.includes(candidate.getDay())) continue;\n        // days of the DTSTART week before DTSTART are not part of the series\n        if (candidate.getTime() < zonedStart.getTime()) continue;\n        week.push(candidate);\n      }\n      return week;\n    }\n    if (rule.freq === \"monthly\") {\n      const anchor = addMonths(zonedStart, period * interval);\n      const year = anchor.getFullYear();\n      const month = anchor.getMonth();\n      return monthDays(year, month)\n        .map((day) => zonedDate(year, month, day))\n        .filter((c) => c.getTime() >= zonedStart.getTime());\n    }\n    // yearly\n    const year = addYears(zonedStart, period * interval).getFullYear();\n    const dates: TZDate[] = [];\n    for (const month of yearlyMonths) {\n      for (const day of monthDays(year, month - 1)) {\n        dates.push(zonedDate(year, month - 1, day));\n      }\n    }\n    return dates.filter((c) => c.getTime() >= zonedStart.getTime());\n  };\n\n  /** Candidates of one period with the LIMIT filters applied. */\n  const candidatesFor = (period: number): TZDate[] =>\n    hasLimits ? periodCandidates(period).filter(passesLimits) : periodCandidates(period);\n\n  /** Earliest/latest instant a period can produce - loop bounds without expanding it. */\n  const periodEdge = (period: number, edge: \"first\" | \"last\"): TZDate => {\n    if (rule.freq === \"daily\") return addDays(zonedStart, period * interval);\n    if (rule.freq === \"weekly\") {\n      const base = addWeeks(zonedStart, period * interval);\n      if (!weeklyDays) return base;\n      return addDays(base, (edge === \"first\" ? 0 : 6) - fromWeekStart(base.getDay()));\n    }\n    if (rule.freq === \"monthly\") {\n      const anchor = addMonths(zonedStart, period * interval);\n      const year = anchor.getFullYear();\n      const month = anchor.getMonth();\n      return zonedDate(year, month, edge === \"first\" ? 1 : daysInMonth(year, month));\n    }\n    const year = addYears(zonedStart, period * interval).getFullYear();\n    const month = (edge === \"first\" ? yearlyMonths[0] : yearlyMonths[yearlyMonths.length - 1]) - 1;\n    return zonedDate(year, month, edge === \"first\" ? 1 : daysInMonth(year, month));\n  };\n\n  // O(1) fast-forward: land a couple of periods before the window instead of\n  // iterating from DTSTART, so years-old series still reach the visible range.\n  const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime();\n  const stepMs = PERIOD_MS[rule.freq] * interval;\n  let startPeriod = aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0;\n  // mean-length drift is bounded well under one period - refine forward\n  while (periodEdge(startPeriod, \"last\").getTime() + durationMs <= range.start.getTime()) {\n    startPeriod++;\n  }\n\n  // series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact\n  let index = 0;\n  if (startPeriod > 0) {\n    if (hasMonthDayParts || hasLimits) {\n      // per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT\n      // filter that empties a whole period) - sum them\n      for (let period = 0; period < startPeriod; period++) {\n        index += candidatesFor(period).length;\n      }\n    } else if (weeklyDays) {\n      // week 0 only counts selected weekdays at/after DTSTART's, inside the\n      // WKST-aligned week that holds it\n      const anchorOffset = fromWeekStart(zonedStart.getDay());\n      const firstWeek = weeklyDays.filter((d) => fromWeekStart(d) >= anchorOffset).length;\n      index = firstWeek + (startPeriod - 1) * weeklyDays.length;\n    } else {\n      index = startPeriod; // one occurrence per period\n    }\n  }\n\n  const occurrences: EventCalendarOccurrence<TData>[] = [];\n\n  const pushIfVisible = (rawStart: Date) => {\n    // normalize to a plain instant so consumers never receive zone-carrying\n    // TZDate instances (mixed-zone formatting bugs)\n    const start = new Date(rawStart.getTime());\n    if (exTimes.has(start.getTime())) return;\n    const end = endFor(start);\n    if (start < range.end && end > range.start) {\n      occurrences.push({\n        key: `${event.id}::${start.toISOString()}`,\n        eventId: event.id,\n        event,\n        start,\n        end,\n        allDay,\n        isRecurring: true,\n        recurrenceIndex: index,\n      });\n    }\n  };\n\n  let iterations = 0;\n  let period = startPeriod;\n  while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {\n    iterations++;\n    if (rule.count !== undefined && index >= rule.count) break;\n    // whole-period bounds: never break on a mid-period weekday/day-of-month,\n    // so earlier candidates of the final period are still emitted\n    const earliest = periodEdge(period, \"first\");\n    if (earliest.getTime() >= range.end.getTime()) break;\n    if (rule.until && earliest.getTime() > rule.until.getTime()) break;\n\n    let ended = false;\n    for (const candidate of candidatesFor(period)) {\n      if (rule.count !== undefined && index >= rule.count) {\n        ended = true;\n        break;\n      }\n      if (rule.until && candidate.getTime() > rule.until.getTime()) {\n        ended = true;\n        break;\n      }\n      pushIfVisible(candidate);\n      index++;\n      if (occurrences.length >= MAX_OCCURRENCES) {\n        ended = true;\n        break;\n      }\n    }\n    if (ended) break;\n    period++;\n  }\n\n  // RDATE: extra instants join the set (deduped against generated starts and\n  // exclusions) with the same wall-time duration. Sorted so direct consumers\n  // still receive chronological order (buildEventIndex re-sorts regardless).\n  if (rule.rDates?.length) {\n    const seen = new Set(occurrences.map((o) => o.start.getTime()));\n    for (const rDate of rule.rDates) {\n      const start = new Date(rDate.getTime());\n      if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue;\n      const end = endFor(start);\n      if (start >= range.end || end <= range.start) continue;\n      seen.add(start.getTime());\n      occurrences.push({\n        key: `${event.id}::${start.toISOString()}`,\n        eventId: event.id,\n        event,\n        start,\n        end,\n        allDay,\n        isRecurring: true,\n        // keep counting past the generated instants: an RDATE with no index\n        // would fall back to undefined and break any consumer that identifies\n        // an instance by its position in the series\n        recurrenceIndex: index++,\n      });\n    }\n    occurrences.sort((a, b) => a.start.getTime() - b.start.getTime());\n  }\n\n  return occurrences;\n}\n\nexport {\n  EventCalendarRecurrenceError,\n  expandRecurrence,\n  formatRRuleString,\n  MAX_OCCURRENCES,\n  parseRRuleString,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-recurrence.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-resource-view.tsx",
      "content": "// Title: Event Calendar Resource View\n// Description: Resource-columns day grid for booking scenarios - one time axis, one column per resource, full drag, resize, and drag-create.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport type * as React from \"react\";\nimport { type CSSProperties, useEffect, useMemo, useRef } from \"react\";\nimport { ScrollArea } from \"@/core/scroll-area/scroll-area\";\nimport { addDays, addMinutes } from \"@/lib/date-fns-compat/index\";\nimport {\n  EventCalendarViewContext,\n  useEventCalendar,\n  useEventCalendarDay,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarViewConfig,\n  useEventCalendarViewSettings,\n} from \"./event-calendar\";\nimport { useEventCalendarGestures, wasRecentChipPress, wasRecentDrag } from \"./event-calendar-dnd\";\nimport {\n  EVENT_CALENDAR_GHOST,\n  EVENT_CALENDAR_SLOT_DRAFT,\n  EventCalendarEvent,\n} from \"./event-calendar-event\";\nimport {\n  flattenResources,\n  getDayKey,\n  getDayTotalMinutes,\n  packTimedSegments,\n  resolveOffDay,\n  snapMinutes,\n  toZoned,\n  zonedStartOfDay,\n} from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport {\n  EventCalendarNowIndicator,\n  EventCalendarTimeGutter,\n  minuteBlockStyle,\n} from \"./event-calendar-time-grid\";\nimport type { EventCalendarResource, EventCalendarSegment } from \"./event-calendar-types\";\n\nconst EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = [];\n\ninterface EventCalendarResourceViewProps extends React.ComponentProps<\"div\"> {\n  dayStartHour?: number;\n  dayEndHour?: number;\n  showAllDay?: boolean;\n  /** Gutter/gridline interval in minutes; defaults to the interval view config. */\n  interval?: number;\n}\n\n/** Leaf resources become booking columns for the anchor day. */\nfunction EventCalendarResourceView({\n  className,\n  dayStartHour,\n  dayEndHour,\n  showAllDay = true,\n  interval: intervalProp,\n  ...props\n}: EventCalendarResourceViewProps) {\n  const instance = useEventCalendar();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { effective } = useEventCalendarViewSettings();\n  const anchorDate = useEventCalendarSelector((state) => state.date, {\n    isEqual: (a, b) => a.getTime() === b.getTime(),\n  });\n\n  const startHour = dayStartHour ?? settings.dayStartHour;\n  const endHour = dayEndHour ?? settings.dayEndHour;\n  const interval = Math.min(Math.max(intervalProp ?? viewConfig.interval, 5), 240);\n  const contained = viewConfig.scrollMode !== \"page\";\n  const day = zonedStartOfDay(anchorDate, settings.timeZone);\n\n  const resources = useMemo(\n    () =>\n      flattenResources(settings.resources)\n        .filter(({ resource }) => !resource.children?.length)\n        .map(({ resource }) => resource),\n    [settings.resources],\n  );\n\n  // Initial scroll + api.scrollToTime (same contract as the time grid)\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: switching scrollbars swaps the viewport element, so re-measure\n  useEffect(() => {\n    if (!contained) return;\n    const el = scrollRef.current;\n    if (!el) return;\n    const viewport = el.querySelector<HTMLElement>(\"[data-slot=scroll-area-viewport]\");\n    const slotRow = el.querySelector<HTMLElement>(\"[data-slot=event-calendar-time-gutter] > div\");\n    const slotPx = slotRow?.getBoundingClientRect().height || 64;\n    const pxPerMinute = slotPx / interval;\n    const scrollTo = (minutes: number) => {\n      // keep the hour label above the target line visible (it hangs -top-2)\n      viewport?.scrollTo({\n        top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),\n      });\n    };\n    scrollTo(viewConfig.scrollToHour * 60);\n    instance.internals.registerScrollHandler((time) => {\n      const minutes =\n        typeof time === \"number\"\n          ? time\n          : toZoned(time, settings.timeZone).getHours() * 60 +\n            toZoned(time, settings.timeZone).getMinutes();\n      scrollTo(minutes);\n    });\n    // Classic (width-consuming) scrollbars squeeze the scrolling track while\n    // the header/all-day rows outside keep full width, drifting the column\n    // borders. Mirror the measured gutter onto those rows via a CSS var -\n    // 0px for overlay scrollbars and the custom ScrollArea, so both modes\n    // lay out identically.\n    const root = el.closest<HTMLElement>(\n      \"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]\",\n    );\n    const syncScrollbarGutter = () => {\n      root?.style.setProperty(\n        \"--ec-scrollbar-w\",\n        `${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`,\n      );\n    };\n    syncScrollbarGutter();\n    const gutterObserver = viewport ? new ResizeObserver(syncScrollbarGutter) : null;\n    if (viewport) gutterObserver?.observe(viewport);\n    return () => {\n      instance.internals.registerScrollHandler(null);\n      gutterObserver?.disconnect();\n    };\n  }, [\n    contained,\n    instance,\n    settings.timeZone,\n    startHour,\n    interval,\n    viewConfig.scrollToHour,\n    // scrollbars custom<->native swaps the scroller DOM: re-bind the\n    // viewport, the scroll wiring, and the measured --ec-scrollbar-w\n    viewConfig.scrollbars,\n  ]);\n\n  const slots = useMemo(() => {\n    const result: number[] = [];\n    for (let m = startHour * 60; m < endHour * 60; m += interval) {\n      result.push(m);\n    }\n    return result;\n  }, [startHour, endHour, interval]);\n\n  // All-day segments for renderAllDaySection - the same index bucket the\n  // cells read; inert (stable empty array, so the subscription never\n  // re-renders) while the override is unset.\n  const allDaySegments = useEventCalendarSelector<unknown, EventCalendarSegment[]>(\n    () =>\n      viewConfig.renderAllDaySection\n        ? (instance.internals.getIndex().byDay.get(getDayKey(day, settings.timeZone))?.allDay ??\n          EMPTY_ALL_DAY_SEGMENTS)\n        : EMPTY_ALL_DAY_SEGMENTS,\n    {\n      isEqual: (a, b) =>\n        a === b || (a.length === b.length && a.every((segment, i) => segment === b[i])),\n    },\n  );\n\n  const gridTemplateColumns = `repeat(${resources.length || 1}, minmax(var(--ec-resource-col-min,8rem), 1fr))`;\n\n  const track = (\n    <div className=\"relative flex\">\n      {/* shared gutter component, so renderTimeGutterSlot and\n          classNames.timeGutter customizations apply here too */}\n      <EventCalendarTimeGutter\n        days={[day]}\n        slots={slots}\n        startHour={startHour}\n        interval={interval}\n      />\n      <div className=\"grid min-w-0 flex-1\" style={{ gridTemplateColumns }}>\n        {resources.map((resource) => (\n          <EventCalendarResourceColumn\n            key={resource.id}\n            resource={resource}\n            day={day}\n            startHour={startHour}\n            endHour={endHour}\n            interval={interval}\n          />\n        ))}\n      </div>\n      {effective.nowIndicator && (\n        <EventCalendarNowIndicator days={[day]} startHour={startHour} endHour={endHour} />\n      )}\n    </div>\n  );\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-resource-view\",\n    \"data-view\": \"resource\",\n    className: cn(\n      \"flex flex-col border-t\",\n      contained && \"min-h-0 flex-1 overflow-hidden\",\n      viewConfig.classNames?.timeGrid,\n      className,\n    ),\n    style: { \"--ec-hour-height\": \"4rem\" } as CSSProperties,\n    children: (\n      <>\n        {/* Resource header row */}\n        <div\n          className={cn(\n            \"flex border-b pe-(--ec-scrollbar-w,0px)\",\n            !contained && \"bg-background sticky top-(--ec-sticky-offset,0px) z-20\",\n            viewConfig.classNames?.timeGridHeader,\n          )}\n        >\n          <div className=\"w-(--ec-gutter-width,4.5rem) shrink-0 border-e\" />\n          <div className=\"grid min-w-0 flex-1\" style={{ gridTemplateColumns }}>\n            {resources.map((resource) => (\n              <div\n                key={resource.id}\n                data-slot=\"event-calendar-resource-header\"\n                className={cn(\n                  \"min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0\",\n                  viewConfig.classNames?.resourceHeader,\n                )}\n              >\n                {viewConfig.renderResourceHeader?.({ resource }) ?? resource.title}\n              </div>\n            ))}\n          </div>\n        </div>\n        {/* All-day row per resource */}\n        {showAllDay && (\n          <div\n            data-slot=\"event-calendar-all-day-section\"\n            className={cn(\n              \"flex border-b pe-(--ec-scrollbar-w,0px)\",\n              viewConfig.classNames?.allDaySection,\n            )}\n          >\n            {viewConfig.renderAllDaySection?.({\n              days: [day],\n              segments: allDaySegments,\n            }) ?? (\n              <>\n                <div\n                  className={cn(\n                    // pt-1.5 matches the all-day cell's top inset; the inner box is\n                    // one bar-row tall and centers the label so it sits on the SAME\n                    // baseline as the first all-day chip and stays top-aligned when\n                    // the chips wrap onto more lanes (mirrors the time-grid label)\n                    \"text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5\",\n                    viewConfig.classNames?.allDayLabel,\n                  )}\n                >\n                  <span className=\"flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end\">\n                    {settings.i18n.labels.allDay}\n                  </span>\n                </div>\n                <div className=\"grid min-w-0 flex-1\" style={{ gridTemplateColumns }}>\n                  {resources.map((resource) => (\n                    <EventCalendarResourceAllDayCell\n                      key={resource.id}\n                      resource={resource}\n                      day={day}\n                    />\n                  ))}\n                </div>\n              </>\n            )}\n          </div>\n        )}\n        {contained ? (\n          <div ref={scrollRef} className=\"min-h-0 flex-1\">\n            {viewConfig.scrollbars === \"native\" ? (\n              <div\n                data-slot=\"scroll-area-viewport\"\n                data-ec-native-scroll=\"\"\n                className=\"h-full overflow-y-auto\"\n              >\n                {track}\n              </div>\n            ) : (\n              <ScrollArea data-slot=\"scroll-area-viewport\" className=\"h-full\">\n                {track}\n              </ScrollArea>\n            )}\n          </div>\n        ) : (\n          track\n        )}\n      </>\n    ),\n  };\n\n  return (\n    <EventCalendarViewContext.Provider value={{ view: \"resource\" }}>\n      <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />\n    </EventCalendarViewContext.Provider>\n  );\n}\n\nfunction EventCalendarResourceAllDayCell({\n  resource,\n  day,\n}: {\n  resource: EventCalendarResource;\n  day: Date;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { effective } = useEventCalendarViewSettings();\n  const gestures = useEventCalendarGestures();\n  const { segments } = useEventCalendarDay(day);\n  const mine = segments.allDay.filter(\n    (segment) => segment.occurrence.event.resourceId === resource.id,\n  );\n  const dayStart = zonedStartOfDay(day, settings.timeZone);\n  const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1);\n  const isOff = resolveOffDay(\n    day,\n    settings.timeZone,\n    effective.offDays\n      ? typeof viewConfig.offDays === \"object\"\n        ? viewConfig.offDays\n        : true\n      : false,\n    settings.weekendDays,\n  );\n  const offClassName =\n    (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) || \"bg-muted/25\";\n  const isDropTarget = useEventCalendarSelector<unknown, \"valid\" | \"invalid\" | null>((state) => {\n    const drag = state.drag;\n    if (!drag?.proposedDayGranular) return null;\n    const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart;\n    if (!covered) return null;\n    return drag.valid ? \"valid\" : \"invalid\";\n  });\n  // Slot-draft highlight, mirroring the time-grid all-day cell. The dnd\n  // layer's all-day create branch does not plumb resourceId into the draft,\n  // so every resource cell covering the day highlights together.\n  const inDraft = useEventCalendarSelector<unknown, boolean>((state) => {\n    const draft = state.slotDraft;\n    if (!draft?.allDay) return false;\n    return draft.start < dayEnd && draft.end > dayStart;\n  });\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: pointer gesture surface (drag to create); keyboard users create through the day add buttons\n    <div\n      data-slot=\"event-calendar-all-day-cell\"\n      // data-ec-day makes this a DnD day target: without it collectSurface()\n      // finds no cells and dragging an all-day chip silently converts it to\n      // a timed event via the column branch\n      data-ec-day={dayStart.getTime()}\n      data-drop-target={isDropTarget ?? undefined}\n      data-off={isOff || undefined}\n      className={cn(\n        // reserve one bar row so the all-day row keeps the same height with or\n        // without events, matching the time-grid all-day row (which reserves\n        // the same via its bars-grid minHeight)\n        \"relative flex min-h-[calc(var(--ec-month-bar-h,1.625rem)+0.625rem)] min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0\",\n        isOff && offClassName,\n        viewConfig.dayClassName?.(day),\n        inDraft &&\n          cn(\n            EVENT_CALENDAR_SLOT_DRAFT.surface,\n            EVENT_CALENDAR_SLOT_DRAFT.segment,\n            EVENT_CALENDAR_SLOT_DRAFT.segmentStart,\n            EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,\n            viewConfig.classNames?.slotDraft,\n          ),\n        viewConfig.classNames?.allDayCell,\n      )}\n      onPointerDown={(e) => {\n        if (e.target === e.currentTarget) gestures.beginCreate(e, day, true);\n      }}\n      onClick={(e) => {\n        if (e.target === e.currentTarget && !wasRecentDrag() && !wasRecentChipPress()) {\n          settings.onSlotClick?.(\n            {\n              date: dayStart,\n              allDay: true,\n              view: \"resource\",\n              resourceId: resource.id,\n            },\n            e,\n          );\n        }\n      }}\n    >\n      {mine.map((segment) => (\n        <EventCalendarEvent\n          key={segment.occurrence.key}\n          segment={segment}\n          // one bar-row tall, matching the time-grid all-day bars so the row\n          // height stays identical across views (and equals the reserved min)\n          className=\"h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]\"\n        />\n      ))}\n      {isDropTarget && (\n        <span\n          aria-hidden\n          data-slot=\"event-calendar-drop-indicator\"\n          data-drop-target={isDropTarget}\n          className={cn(\n            \"pointer-events-none absolute inset-0.5 z-10 rounded-sm border border-dashed\",\n            isDropTarget === \"valid\" ? \"border-primary/50\" : \"border-destructive/60\",\n            viewConfig.classNames?.dropIndicator,\n          )}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction EventCalendarResourceColumn({\n  resource,\n  day,\n  startHour,\n  endHour,\n  interval,\n}: {\n  resource: EventCalendarResource;\n  day: Date;\n  startHour: number;\n  endHour: number;\n  interval: number;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { effective } = useEventCalendarViewSettings();\n  const gestures = useEventCalendarGestures();\n  const { segments, isToday } = useEventCalendarDay(day);\n  const isOff = resolveOffDay(\n    day,\n    settings.timeZone,\n    effective.offDays\n      ? typeof viewConfig.offDays === \"object\"\n        ? viewConfig.offDays\n        : true\n      : false,\n    settings.weekendDays,\n  );\n  const offClassName =\n    (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) || \"bg-muted/25\";\n\n  const timeZone = settings.timeZone;\n  const dayStart = zonedStartOfDay(day, timeZone);\n  const totalMinutes = getDayTotalMinutes(day, timeZone);\n  const boundsStartMin = startHour * 60;\n  const boundsEndMin = Math.min(endHour * 60, totalMinutes);\n  const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin);\n\n  // Filter this resource's timed segments and repack per column.\n  // Clones keep the shared index cache untouched. Segments the day bounds clip\n  // away are dropped here too, otherwise they hold a column nobody can see and\n  // leave a phantom empty half beside the first in-bounds chip.\n  const packed = useMemo(() => {\n    const mine = segments.timed\n      .filter((segment) => {\n        if (segment.occurrence.event.resourceId !== resource.id) return false;\n        const startMin = Math.max(segment.startMin ?? 0, boundsStartMin);\n        const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin);\n        return endMin > boundsStartMin && startMin < boundsEndMin;\n      })\n      .map((segment) => ({ ...segment }) as EventCalendarSegment);\n    packTimedSegments(mine);\n    return mine;\n  }, [segments.timed, resource.id, boundsStartMin, boundsEndMin]);\n\n  const dragGhost = useEventCalendarSelector<\n    unknown,\n    {\n      window: [number, number];\n      valid: boolean;\n      kind: string;\n      color?: string;\n      title: string;\n      occurrence: EventCalendarSegment[\"occurrence\"];\n      proposedStart: Date;\n      proposedEnd: Date;\n    } | null\n  >(\n    (state) => {\n      const drag = state.drag;\n      if (!drag || drag.proposedDayGranular) return null;\n      // Moves carry a proposedResourceId (they can cross columns); resizes stay\n      // in place and leave it undefined, so fall back to the event's own\n      // resource - otherwise the resize ghost is filtered out of every column.\n      const targetResourceId = drag.proposedResourceId ?? drag.occurrence.event.resourceId;\n      if (targetResourceId !== resource.id) return null;\n      const from = Math.max(\n        (drag.proposedStart.getTime() - dayStart.getTime()) / 60000,\n        boundsStartMin,\n      );\n      const to = Math.min((drag.proposedEnd.getTime() - dayStart.getTime()) / 60000, boundsEndMin);\n      if (to <= from) return null;\n      return {\n        window: [from, to] as [number, number],\n        valid: drag.valid,\n        kind: drag.kind,\n        color: drag.occurrence.event.color,\n        title: drag.occurrence.event.title,\n        occurrence: drag.occurrence,\n        proposedStart: drag.proposedStart,\n        proposedEnd: drag.proposedEnd,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.window[0] === b.window[0] &&\n          a.window[1] === b.window[1] &&\n          a.valid === b.valid &&\n          a.proposedStart.getTime() === b.proposedStart.getTime() &&\n          a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n    },\n  );\n\n  // Instants behind `draftWindow` below, for the range readout. Same\n  // resource filter, so a draft on a neighbouring resource never labels this\n  // column.\n  const draftRange = useEventCalendarSelector<unknown, { start: Date; end: Date } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft || draft.allDay || draft.resourceId !== resource.id) {\n        return null;\n      }\n      return { start: draft.start, end: draft.end };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.start.getTime() === b.start.getTime() &&\n          a.end.getTime() === b.end.getTime()),\n    },\n  );\n\n  const draftWindow = useEventCalendarSelector<unknown, [number, number] | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft || draft.allDay || draft.resourceId !== resource.id) {\n        return null;\n      }\n      const from = Math.max((draft.start.getTime() - dayStart.getTime()) / 60000, boundsStartMin);\n      const to = Math.min((draft.end.getTime() - dayStart.getTime()) / 60000, boundsEndMin);\n      return to > from ? [from, to] : null;\n    },\n    {\n      isEqual: (a, b) => a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),\n    },\n  );\n\n  return (\n    <div\n      data-slot=\"event-calendar-day-column\"\n      data-today={isToday || undefined}\n      data-off={isOff || undefined}\n      data-ec-day={dayStart.getTime()}\n      data-ec-bounds-start={boundsStartMin}\n      data-ec-bounds-end={boundsEndMin}\n      data-ec-resource={resource.id}\n      data-drop-target={dragGhost ? (dragGhost.valid ? \"valid\" : \"invalid\") : undefined}\n      role=\"group\"\n      aria-label={resource.title}\n      className={cn(\n        \"relative min-w-0 border-e last:border-e-0\",\n        isOff && offClassName,\n        // the resource view is a single day, so today gets no column tint (the\n        // header marks it); only a consumer todayClassName can tint it\n        isToday && viewConfig.todayClassName,\n        viewConfig.dayClassName?.(day),\n        viewConfig.classNames?.dayColumn,\n      )}\n      style={{\n        height: `calc(var(--ec-hour-height) * ${boundsMinutes / 60})`,\n        backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--border)) calc(var(--ec-hour-height) * ${interval / 60}))`,\n      }}\n      onPointerDown={(e) => {\n        if (e.target === e.currentTarget) gestures.beginCreate(e, day, false);\n      }}\n      onClick={(e) => {\n        if (e.target !== e.currentTarget || wasRecentDrag() || wasRecentChipPress()) return;\n        const rect = e.currentTarget.getBoundingClientRect();\n        const pxPerMinute = rect.height / boundsMinutes;\n        const minutes = snapMinutes(\n          boundsStartMin + (e.clientY - rect.top) / pxPerMinute,\n          settings.snapDuration,\n        );\n        const clamped = Math.min(\n          Math.max(minutes, boundsStartMin),\n          boundsEndMin - settings.slotDuration,\n        );\n        settings.onSlotClick?.(\n          {\n            date: addMinutes(dayStart, clamped),\n            end: addMinutes(dayStart, clamped + settings.slotDuration),\n            allDay: false,\n            view: \"resource\",\n            resourceId: resource.id,\n          },\n          e,\n        );\n      }}\n    >\n      {packed.map((segment) => {\n        const startMin = Math.max(segment.startMin ?? 0, boundsStartMin);\n        const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin);\n        if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null;\n        const columnCount = segment.columnCount ?? 1;\n        const column = segment.column ?? 0;\n        const span = segment.columnSpan ?? 1;\n        const zIndex = segment.occurrence.event.zIndex ?? 10 + column;\n        // Strict side-by-side columns - no cascade overlap (fade-truncate +\n        // hover reveal carry the legibility); the ring separates neighbors.\n        const colPct = 100 / columnCount;\n        return (\n          <div\n            key={segment.occurrence.key}\n            // min-h keeps 15-min chips readable (Google-style: the block may\n            // slightly outgrow its true window); hover raises a squeezed\n            // cascade chip above its overlapping neighbors\n            className=\"absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40\"\n            style={\n              {\n                ...minuteBlockStyle(startMin, endMin, boundsStartMin),\n                left: `${column * colPct}%`,\n                width: `${span * colPct}%`,\n                \"--ec-z\": zIndex,\n              } as CSSProperties\n            }\n          >\n            <EventCalendarEvent\n              segment={segment}\n              className={cn(\n                columnCount > 1 && \"ring-background ring-1\",\n                // short chips: single centered row, exact-fit line height so\n                // the title never slices mid-glyph\n                endMin - startMin < viewConfig.compactEventMinutes\n                  ? \"h-full gap-1 py-0 leading-4\"\n                  : \"h-full flex-col items-start justify-start gap-0 py-1\",\n                viewConfig.classNames?.timedChip,\n              )}\n            />\n          </div>\n        );\n      })}\n      {/* Standardized ghost (EVENT_CALENDAR_GHOST): faint drop placeholder\n          for moves (the cursor-attached carry clone owns the visual), dashed\n          clone for resizes, destructive marking when invalid. */}\n      {dragGhost && (\n        <div\n          data-slot=\"event-calendar-drag-ghost\"\n          data-kind={dragGhost.kind}\n          data-drop-invalid={!dragGhost.valid || undefined}\n          className={cn(\n            \"pointer-events-none absolute inset-x-0.5 z-50 min-h-(--ec-event-min-h,1.5rem)\",\n            dragGhost.kind === \"move\"\n              ? cn(EVENT_CALENDAR_GHOST.move, !dragGhost.valid && EVENT_CALENDAR_GHOST.invalid)\n              : cn(\n                  EVENT_CALENDAR_GHOST.resize,\n                  !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize,\n                ),\n            viewConfig.classNames?.dragGhost,\n          )}\n          style={\n            {\n              ...minuteBlockStyle(dragGhost.window[0], dragGhost.window[1], boundsStartMin),\n              \"--ec-event-color\": dragGhost.color ?? \"var(--primary)\",\n            } as CSSProperties\n          }\n        >\n          {dragGhost.kind !== \"move\" && (\n            <EventCalendarEvent\n              preview\n              segment={{\n                occurrence: {\n                  ...dragGhost.occurrence,\n                  start: dragGhost.proposedStart,\n                  end: dragGhost.proposedEnd,\n                  allDay: false,\n                },\n                day,\n                isStart: true,\n                isEnd: true,\n                continuesBefore: false,\n                continuesAfter: false,\n                startMin: dragGhost.window[0],\n                endMin: dragGhost.window[1],\n              }}\n              className={cn(\n                dragGhost.window[1] - dragGhost.window[0] < viewConfig.compactEventMinutes\n                  ? \"h-full gap-1 py-0 leading-4\"\n                  : \"h-full flex-col items-start justify-start gap-0 py-1\",\n                viewConfig.classNames?.timedChip,\n                \"inset-ring-0\",\n                !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent,\n              )}\n            />\n          )}\n        </div>\n      )}\n      {draftWindow && (\n        <div\n          data-slot=\"event-calendar-slot-draft\"\n          className={cn(\n            EVENT_CALENDAR_SLOT_DRAFT.box,\n            \"pointer-events-none absolute inset-x-0.5 z-40 overflow-hidden\",\n            viewConfig.classNames?.slotDraft,\n          )}\n          style={minuteBlockStyle(draftWindow[0], draftWindow[1], boundsStartMin)}\n        >\n          {draftRange && (\n            <span className={cn(\"block\", EVENT_CALENDAR_SLOT_DRAFT.label)}>\n              {settings.i18n.functions.formatEventTime(\n                toZoned(draftRange.start, settings.timeZone),\n                toZoned(draftRange.end, settings.timeZone),\n                false,\n                { locale: settings.locale },\n              )}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport type { EventCalendarResourceViewProps };\nexport { EventCalendarResourceView };\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-resource-view.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-time-grid.tsx",
      "content": "// Title: Event Calendar Time Grid\n// Description: Shared week/day/N-days engine - hour gutter, minute-positioned events, all-day row, drag ghosts, now indicator, and configurable scrolling.\n\n\"use client\";\n\nimport { cn } from \"cn\";\nimport type * as React from \"react\";\nimport { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { ScrollArea } from \"@/core/scroll-area/scroll-area\";\nimport { addDays, addMinutes, differenceInMinutes, format } from \"@/lib/date-fns-compat/index\";\nimport {\n  EventCalendarViewContext,\n  useEventCalendar,\n  useEventCalendarDay,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarViewConfig,\n  useEventCalendarViewContext,\n  useEventCalendarViewSettings,\n} from \"./event-calendar\";\nimport { useEventCalendarGestures, wasRecentChipPress, wasRecentDrag } from \"./event-calendar-dnd\";\nimport {\n  EVENT_CALENDAR_GHOST,\n  EVENT_CALENDAR_SLOT_DRAFT,\n  EventCalendarDayAddButton,\n  EventCalendarEvent,\n} from \"./event-calendar-event\";\nimport {\n  getDayKey,\n  getDayTotalMinutes,\n  getRangeKey,\n  packTimedSegments,\n  resolveOffDay,\n  snapMinutes,\n  toZoned,\n  zonedStartOfDay,\n} from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type {\n  CalendarView,\n  EventCalendarDateRange,\n  EventCalendarDragState,\n  EventCalendarSegment,\n} from \"./event-calendar-types\";\n\n/** Current time, refreshed on an interval and on tab focus. */\nfunction useNow(intervalMs = 30_000): Date {\n  const [now, setNow] = useState(() => new Date());\n  useEffect(() => {\n    const tick = () => setNow(new Date());\n    const id = setInterval(tick, intervalMs);\n    document.addEventListener(\"visibilitychange\", tick);\n    window.addEventListener(\"focus\", tick);\n    return () => {\n      clearInterval(id);\n      document.removeEventListener(\"visibilitychange\", tick);\n      window.removeEventListener(\"focus\", tick);\n    };\n  }, [intervalMs]);\n  return now;\n}\n\nconst EMPTY_ALL_DAY_SEGMENTS: EventCalendarSegment[] = [];\n\ninterface EventCalendarTimeGridProps extends React.ComponentProps<\"div\"> {\n  view: Extract<CalendarView, \"week\" | \"day\" | \"days\">;\n  dayStartHour?: number;\n  dayEndHour?: number;\n  showAllDay?: boolean;\n  /** Gutter/gridline interval in minutes; defaults to the interval view config. */\n  interval?: number;\n}\n\nfunction EventCalendarTimeGrid({\n  view,\n  className,\n  dayStartHour,\n  dayEndHour,\n  showAllDay = true,\n  interval: intervalProp,\n  ...props\n}: EventCalendarTimeGridProps) {\n  const instance = useEventCalendar();\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const visibleRange = useEventCalendarSelector<unknown, EventCalendarDateRange>(\n    (state) => state.visibleRange,\n    {\n      isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),\n    },\n  );\n\n  const startHour = dayStartHour ?? settings.dayStartHour;\n  const endHour = dayEndHour ?? settings.dayEndHour;\n  const interval = Math.min(Math.max(intervalProp ?? viewConfig.interval, 5), 240);\n  const contained = viewConfig.scrollMode !== \"page\";\n\n  const { effective } = useEventCalendarViewSettings();\n  const days = useMemo(() => {\n    const result: Date[] = [];\n    let cursor = zonedStartOfDay(visibleRange.start, settings.timeZone);\n    while (cursor < visibleRange.end) {\n      result.push(cursor);\n      cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone);\n    }\n    if (effective.weekends || view === \"day\") return result;\n    // Same weekend definition the month view filters on, so the toggle cannot\n    // hide one set of days here and another one there.\n    const filtered = result.filter(\n      (day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()),\n    );\n    // A short N-days window landing entirely on the weekend would otherwise\n    // filter to nothing and emit an invalid repeat(0, ...) track.\n    return filtered.length ? filtered : result;\n  }, [visibleRange, settings.timeZone, settings.weekendDays, effective.weekends, view]);\n\n  // Initial scroll to scrollToHour + api.scrollToTime registration (contained)\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  // biome-ignore lint/correctness/useExhaustiveDependencies: switching scrollbars swaps the viewport element, so re-measure\n  useEffect(() => {\n    if (!contained) return;\n    const el = scrollRef.current;\n    if (!el) return;\n    const viewport = el.querySelector<HTMLElement>(\"[data-slot=scroll-area-viewport]\");\n    // Measure a rendered slot row - the CSS var is in rem, rects are in px.\n    const slotRow = el.querySelector<HTMLElement>(\"[data-slot=event-calendar-time-gutter] > div\");\n    const slotPx = slotRow?.getBoundingClientRect().height || 64;\n    const pxPerMinute = slotPx / interval;\n    const scrollTo = (minutes: number) => {\n      // keep the hour label above the target line visible (it hangs -top-2)\n      viewport?.scrollTo({\n        top: Math.max(0, (minutes - startHour * 60) * pxPerMinute - 12),\n      });\n    };\n    scrollTo(viewConfig.scrollToHour * 60);\n    instance.internals.registerScrollHandler((time) => {\n      const minutes =\n        typeof time === \"number\"\n          ? time\n          : toZoned(time, settings.timeZone).getHours() * 60 +\n            toZoned(time, settings.timeZone).getMinutes();\n      scrollTo(minutes);\n    });\n    // Classic (width-consuming) scrollbars squeeze the scrolling track while\n    // the header/all-day rows outside keep full width, drifting the column\n    // borders. Mirror the measured gutter onto those rows via a CSS var -\n    // 0px for overlay scrollbars and the custom ScrollArea, so both modes\n    // lay out identically.\n    const root = el.closest<HTMLElement>(\n      \"[data-slot=event-calendar-time-grid], [data-slot=event-calendar-resource-view]\",\n    );\n    const syncScrollbarGutter = () => {\n      root?.style.setProperty(\n        \"--ec-scrollbar-w\",\n        `${viewport ? viewport.offsetWidth - viewport.clientWidth : 0}px`,\n      );\n    };\n    syncScrollbarGutter();\n    const gutterObserver = viewport ? new ResizeObserver(syncScrollbarGutter) : null;\n    if (viewport) gutterObserver?.observe(viewport);\n    return () => {\n      instance.internals.registerScrollHandler(null);\n      gutterObserver?.disconnect();\n    };\n  }, [\n    contained,\n    instance,\n    settings.timeZone,\n    startHour,\n    interval,\n    viewConfig.scrollToHour,\n    // scrollbars custom<->native swaps the scroller DOM: re-bind the\n    // viewport, the scroll wiring, and the measured --ec-scrollbar-w\n    viewConfig.scrollbars,\n  ]);\n\n  // Gutter slots in minutes from the zoned day start\n  const slots = useMemo(() => {\n    const result: number[] = [];\n    for (let m = startHour * 60; m < endHour * 60; m += interval) {\n      result.push(m);\n    }\n    return result;\n  }, [startHour, endHour, interval]);\n\n  // Section-level all-day segments for renderAllDaySection - reads the same\n  // per-day index buckets the cells subscribe to; inert (stable empty array,\n  // so the subscription never re-renders) while the override is unset.\n  const allDaySegments = useEventCalendarSelector<unknown, EventCalendarSegment[]>(\n    () => {\n      if (!viewConfig.renderAllDaySection) return EMPTY_ALL_DAY_SEGMENTS;\n      const byDay = instance.internals.getIndex().byDay;\n      const result = days.flatMap(\n        (day) => byDay.get(getDayKey(day, settings.timeZone))?.allDay ?? [],\n      );\n      return result.length ? result : EMPTY_ALL_DAY_SEGMENTS;\n    },\n    {\n      isEqual: (a, b) =>\n        a === b || (a.length === b.length && a.every((segment, i) => segment === b[i])),\n    },\n  );\n\n  const gridTemplateColumns = `repeat(${days.length}, minmax(var(--ec-day-col-min,0px), 1fr))`;\n\n  const track = (\n    <div className=\"relative flex\">\n      <EventCalendarTimeGutter\n        days={days}\n        slots={slots}\n        startHour={startHour}\n        interval={interval}\n      />\n      <div className=\"grid min-w-0 flex-1\" style={{ gridTemplateColumns }}>\n        {days.map((day) => (\n          <EventCalendarDayColumn\n            key={day.getTime()}\n            day={day}\n            startHour={startHour}\n            endHour={endHour}\n            interval={interval}\n          />\n        ))}\n      </div>\n      {effective.nowIndicator && (\n        <EventCalendarNowIndicator days={days} startHour={startHour} endHour={endHour} />\n      )}\n    </div>\n  );\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar-time-grid\",\n    \"data-view\": view,\n    className: cn(\n      \"flex flex-col border-t\",\n      contained && \"min-h-0 flex-1 overflow-hidden\",\n      viewConfig.classNames?.timeGrid,\n      className,\n    ),\n    style: { \"--ec-hour-height\": \"4rem\" } as CSSProperties,\n    children: (\n      <>\n        {/* Day-header row (sticky below the nav in page scroll mode) */}\n        <div\n          className={cn(\n            \"flex border-b pe-(--ec-scrollbar-w,0px)\",\n            !contained && \"bg-background sticky top-(--ec-sticky-offset,0px) z-20\",\n            viewConfig.classNames?.timeGridHeader,\n          )}\n        >\n          <div className=\"w-(--ec-gutter-width,4.5rem) shrink-0 border-e\" />\n          <div className=\"grid min-w-0 flex-1\" style={{ gridTemplateColumns }}>\n            {days.map((day) => (\n              <EventCalendarDayHeader key={day.getTime()} day={day} view={view} />\n            ))}\n          </div>\n        </div>\n        {/* All-day row */}\n        {showAllDay && (\n          <div\n            data-slot=\"event-calendar-all-day-section\"\n            className={cn(\n              \"flex border-b pe-(--ec-scrollbar-w,0px)\",\n              viewConfig.classNames?.allDaySection,\n            )}\n          >\n            {viewConfig.renderAllDaySection?.({\n              days,\n              segments: allDaySegments,\n            }) ?? (\n              <>\n                <div\n                  className={cn(\n                    // pt-1.5 matches the bar overlay's top inset; the inner box\n                    // is one bar-row tall and centers the label so it sits on\n                    // the SAME baseline as the first all-day chip and stays top-\n                    // aligned when the chips wrap onto more lanes\n                    \"text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5\",\n                    viewConfig.classNames?.allDayLabel,\n                  )}\n                >\n                  <span className=\"flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end\">\n                    {settings.i18n.labels.allDay}\n                  </span>\n                </div>\n                <EventCalendarAllDayBars days={days} gridTemplateColumns={gridTemplateColumns} />\n              </>\n            )}\n          </div>\n        )}\n        {/* Time track: internal scroll (contained) or document flow (page) */}\n        {contained ? (\n          <div ref={scrollRef} className=\"min-h-0 flex-1\">\n            {viewConfig.scrollbars === \"native\" ? (\n              <div\n                data-slot=\"scroll-area-viewport\"\n                data-ec-native-scroll=\"\"\n                className=\"h-full overflow-y-auto\"\n              >\n                {track}\n              </div>\n            ) : (\n              <ScrollArea data-slot=\"scroll-area-viewport\" className=\"h-full\">\n                {track}\n              </ScrollArea>\n            )}\n          </div>\n        ) : (\n          track\n        )}\n      </>\n    ),\n  };\n\n  return (\n    <EventCalendarViewContext.Provider value={{ view }}>\n      <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, props)} />\n    </EventCalendarViewContext.Provider>\n  );\n}\n\nfunction EventCalendarDayHeader({ day, view }: { day: Date; view: CalendarView }) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { isToday } = useEventCalendarDay(day);\n  return (\n    <div\n      data-slot=\"event-calendar-day-header\"\n      data-today={isToday || undefined}\n      className={cn(\n        \"data-today:text-primary group/ec-cell flex min-w-0 items-center gap-1 border-e px-2 py-1.5 font-medium last:border-e-0\",\n        isToday && viewConfig.todayClassName,\n      )}\n    >\n      <span className=\"min-w-0 flex-1 truncate\">\n        {viewConfig.renderDayHeader?.({ day, view, isToday }) ??\n          format(toZoned(day, settings.timeZone), settings.i18n.formats.timeGridDayHeader, {\n            locale: settings.locale,\n          })}\n      </span>\n      {viewConfig.showDayAddButton && <EventCalendarDayAddButton day={day} view={view} />}\n    </div>\n  );\n}\n\n/**\n * Continuous all-day bars for the week/N-days all-day row: consecutive-day\n * segments of one occurrence merge into a single bar spanning its columns\n * (same treatment as the month view), lane-packed. Returns CLONED segments -\n * the shared per-day segments must stay pristine (see packWeekRowLanes).\n */\nfunction useEventCalendarAllDayBars(days: Date[]): {\n  bars: EventCalendarSegment[];\n  laneCount: number;\n} {\n  const instance = useEventCalendar();\n  const settings = useEventCalendarSettings();\n  return useEventCalendarSelector<unknown, { bars: EventCalendarSegment[]; laneCount: number }>(\n    () => {\n      const byDay = instance.internals.getIndex().byDay;\n      type Bar = {\n        seg: EventCalendarSegment;\n        colStart: number;\n        colEnd: number;\n        isStart: boolean;\n        isEnd: boolean;\n        lane: number;\n      };\n      const merged = new Map<string, Bar>();\n      days.forEach((day, col) => {\n        for (const seg of byDay.get(getDayKey(day, settings.timeZone))?.allDay ?? []) {\n          const key = seg.occurrence.key;\n          const bar = merged.get(key);\n          if (bar) {\n            bar.colEnd = col;\n            bar.isEnd = seg.isEnd;\n          } else {\n            merged.set(key, {\n              seg,\n              colStart: col,\n              colEnd: col,\n              isStart: seg.isStart,\n              isEnd: seg.isEnd,\n              lane: 0,\n            });\n          }\n        }\n      });\n      const packed = Array.from(merged.values()).sort(\n        (a, b) =>\n          a.colStart - b.colStart ||\n          b.colEnd - b.colStart - (a.colEnd - a.colStart) ||\n          a.seg.occurrence.key.localeCompare(b.seg.occurrence.key),\n      );\n      const lanes: boolean[][] = [];\n      for (const bar of packed) {\n        let lane = 0;\n        for (;;) {\n          lanes[lane] ??= new Array(days.length).fill(false);\n          let free = true;\n          for (let c = bar.colStart; c <= bar.colEnd; c++) {\n            if (lanes[lane][c]) {\n              free = false;\n              break;\n            }\n          }\n          if (free) break;\n          lane++;\n        }\n        for (let c = bar.colStart; c <= bar.colEnd; c++) lanes[lane][c] = true;\n        bar.lane = lane;\n      }\n      return {\n        bars: packed.map((bar) => ({\n          ...bar.seg,\n          isStart: bar.isStart,\n          isEnd: bar.isEnd,\n          continuesBefore: !bar.isStart,\n          continuesAfter: !bar.isEnd,\n          colStart: bar.colStart,\n          colSpan: bar.colEnd - bar.colStart + 1,\n          lane: bar.lane,\n        })),\n        laneCount: lanes.length,\n      };\n    },\n    {\n      calendar: instance,\n      isEqual: (a, b) =>\n        a.laneCount === b.laneCount &&\n        a.bars.length === b.bars.length &&\n        a.bars.every((s, i) => {\n          const o = b.bars[i];\n          // Compare the OCCURRENCE by identity, not by key: the key encodes\n          // id+start only, so a title/color/data edit compared equal and the\n          // all-day row kept its stale bars. Occurrences come from the\n          // memoized index (rebuilt exactly when events change), so identity\n          // also subsumes the end-time check it replaces. The positional\n          // fields stay because they are recomputed on every read.\n          return (\n            s.occurrence === o.occurrence &&\n            s.colStart === o.colStart &&\n            s.colSpan === o.colSpan &&\n            s.lane === o.lane &&\n            s.isStart === o.isStart &&\n            s.isEnd === o.isEnd\n          );\n        }),\n    },\n  );\n}\n\n/**\n * The all-day row body: drop-target cells underneath, one continuous bar per\n * occurrence in an overlay grid on top (month-view treatment), plus the\n * standardized day-granular drag ghost for moves/resizes in this lane.\n */\nfunction EventCalendarAllDayBars({\n  days,\n  gridTemplateColumns,\n}: {\n  days: Date[];\n  gridTemplateColumns: string;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { bars, laneCount } = useEventCalendarAllDayBars(days);\n\n  const dragGhost = useEventCalendarSelector<\n    unknown,\n    | (Pick<\n        EventCalendarDragState,\n        \"kind\" | \"valid\" | \"occurrence\" | \"proposedStart\" | \"proposedEnd\"\n      > & {\n        colStart: number;\n        colSpan: number;\n        isStart: boolean;\n        isEnd: boolean;\n        color?: string;\n      })\n    | null\n  >(\n    (state) => {\n      const drag = state.drag;\n      if (!drag?.proposedDayGranular) return null;\n      const tz = settings.timeZone;\n      const startMs = zonedStartOfDay(drag.proposedStart, tz).getTime();\n      const lastMs = zonedStartOfDay(new Date(drag.proposedEnd.getTime() - 1), tz).getTime();\n      let colStart = -1;\n      let colEnd = -1;\n      days.forEach((day, i) => {\n        const t = zonedStartOfDay(day, tz).getTime();\n        if (t >= startMs && t <= lastMs) {\n          if (colStart === -1) colStart = i;\n          colEnd = i;\n        }\n      });\n      if (colStart === -1) return null;\n      return {\n        kind: drag.kind,\n        valid: drag.valid,\n        occurrence: drag.occurrence,\n        proposedStart: drag.proposedStart,\n        proposedEnd: drag.proposedEnd,\n        colStart,\n        colSpan: colEnd - colStart + 1,\n        isStart: zonedStartOfDay(days[colStart], tz).getTime() <= startMs,\n        isEnd: zonedStartOfDay(days[colEnd], tz).getTime() >= lastMs,\n        color: drag.occurrence.event.color,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.colStart === b.colStart &&\n          a.colSpan === b.colSpan &&\n          a.valid === b.valid &&\n          a.kind === b.kind &&\n          a.proposedStart.getTime() === b.proposedStart.getTime() &&\n          a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n    },\n  );\n  const ghostLane = dragGhost\n    ? (bars.find((s) => s.occurrence.key === dragGhost.occurrence.key)?.lane ?? laneCount)\n    : 0;\n  const effectiveLanes = Math.max(laneCount, dragGhost ? ghostLane + 1 : 0);\n\n  return (\n    <div className=\"relative min-w-0 flex-1\">\n      <div\n        className=\"grid h-full\"\n        style={{\n          gridTemplateColumns,\n          // always reserve at least one bar row so the all-day row keeps the\n          // same height whether or not the day has all-day events (an empty\n          // row would otherwise collapse to the label height and misalign)\n          minHeight: `calc(${Math.max(effectiveLanes, 1)} * var(--ec-month-bar-h, 1.625rem) + 0.625rem)`,\n        }}\n      >\n        {days.map((day) => (\n          <EventCalendarAllDayCell key={day.getTime()} day={day} />\n        ))}\n      </div>\n      {(bars.length > 0 || dragGhost) && (\n        <div\n          data-slot=\"event-calendar-all-day-bar-overlay\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 grid pt-1.5\"\n          style={{\n            gridTemplateColumns,\n            gridAutoRows: \"var(--ec-month-bar-h, 1.625rem)\",\n          }}\n        >\n          {bars.map((segment) => (\n            <div\n              key={segment.occurrence.key}\n              className={cn(\"pointer-events-auto min-w-0 px-1\", viewConfig.classNames?.monthBar)}\n              style={{\n                gridColumn: `${(segment.colStart ?? 0) + 1} / span ${segment.colSpan ?? 1}`,\n                gridRow: (segment.lane ?? 0) + 1,\n              }}\n            >\n              <EventCalendarEvent\n                segment={segment}\n                className=\"h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]\"\n              />\n            </div>\n          ))}\n          {dragGhost && (\n            <div\n              aria-hidden\n              className={cn(\"min-w-0 px-1\", viewConfig.classNames?.monthBar)}\n              style={{\n                gridColumn: `${dragGhost.colStart + 1} / span ${dragGhost.colSpan}`,\n                gridRow: ghostLane + 1,\n              }}\n            >\n              <div\n                data-slot=\"event-calendar-drag-ghost\"\n                data-kind={dragGhost.kind}\n                data-drop-invalid={!dragGhost.valid || undefined}\n                className={cn(\n                  \"h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]\",\n                  dragGhost.kind === \"move\"\n                    ? cn(\n                        EVENT_CALENDAR_GHOST.move,\n                        !dragGhost.valid && EVENT_CALENDAR_GHOST.invalid,\n                      )\n                    : cn(\n                        EVENT_CALENDAR_GHOST.resize,\n                        !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize,\n                      ),\n                  viewConfig.classNames?.dragGhost,\n                )}\n                style={\n                  {\n                    \"--ec-event-color\": dragGhost.color ?? \"var(--primary)\",\n                  } as CSSProperties\n                }\n              >\n                {dragGhost.kind !== \"move\" && (\n                  <EventCalendarEvent\n                    preview\n                    segment={{\n                      occurrence: {\n                        ...dragGhost.occurrence,\n                        start: dragGhost.proposedStart,\n                        end: dragGhost.proposedEnd,\n                      },\n                      day: days[dragGhost.colStart] ?? days[0],\n                      isStart: dragGhost.isStart,\n                      isEnd: dragGhost.isEnd,\n                      continuesBefore: !dragGhost.isStart,\n                      continuesAfter: !dragGhost.isEnd,\n                    }}\n                    className={cn(\n                      \"h-full inset-ring-0\",\n                      !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent,\n                    )}\n                  />\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction EventCalendarAllDayCell({ day }: { day: Date }) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { view } = useEventCalendarViewContext();\n  const { effective } = useEventCalendarViewSettings();\n  const gestures = useEventCalendarGestures();\n  const dayStart = zonedStartOfDay(day, settings.timeZone);\n  const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1);\n  const isOff = resolveOffDay(\n    day,\n    settings.timeZone,\n    effective.offDays\n      ? typeof viewConfig.offDays === \"object\"\n        ? viewConfig.offDays\n        : true\n      : false,\n    settings.weekendDays,\n  );\n  const offClassName =\n    (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) || \"bg-muted/25\";\n\n  const isDropTarget = useEventCalendarSelector<unknown, \"valid\" | \"invalid\" | null>((state) => {\n    const drag = state.drag;\n    if (!drag?.proposedDayGranular) return null;\n    const covered = drag.proposedStart < dayEnd && drag.proposedEnd > dayStart;\n    if (!covered) return null;\n    return drag.valid ? \"valid\" : \"invalid\";\n  });\n  const inDraft = useEventCalendarSelector<unknown, { isStart: boolean; isEnd: boolean } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft?.allDay) return null;\n      if (draft.start >= dayEnd || draft.end <= dayStart) return null;\n      return {\n        isStart: draft.start >= dayStart,\n        isEnd: draft.end <= dayEnd,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b || (a !== null && b !== null && a.isStart === b.isStart && a.isEnd === b.isEnd),\n    },\n  );\n\n  // All-day drags select whole DAYS, so this row reads a date range, matching\n  // the month grid. Without it, dragging the all-day strip in week view would\n  // be the one create gesture in the calendar with no readout at all.\n  const allDayDraftRange = useEventCalendarSelector<unknown, { start: Date; end: Date } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft?.allDay) return null;\n      return { start: draft.start, end: draft.end };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.start.getTime() === b.start.getTime() &&\n          a.end.getTime() === b.end.getTime()),\n    },\n  );\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: pointer gesture surface (drag to create); keyboard users create through the day add buttons\n    <div\n      data-slot=\"event-calendar-all-day-cell\"\n      data-ec-day={dayStart.getTime()}\n      data-drop-target={isDropTarget ?? undefined}\n      data-off={isOff || undefined}\n      className={cn(\n        \"relative flex min-w-0 flex-col gap-0.5 border-e px-1 py-1.5 last:border-e-0\",\n        isOff && offClassName,\n        viewConfig.dayClassName?.(day),\n        // No drop-target bg fill on move/resize (see month view) - a subtle\n        // dashed inset outline below marks the target instead.\n        inDraft &&\n          cn(\n            EVENT_CALENDAR_SLOT_DRAFT.surface,\n            EVENT_CALENDAR_SLOT_DRAFT.segment,\n            inDraft.isStart && EVENT_CALENDAR_SLOT_DRAFT.segmentStart,\n            inDraft.isEnd && EVENT_CALENDAR_SLOT_DRAFT.segmentEnd,\n            viewConfig.classNames?.slotDraft,\n          ),\n        viewConfig.classNames?.allDayCell,\n      )}\n      onPointerDown={(e) => {\n        if (e.target === e.currentTarget) gestures.beginCreate(e, day, true);\n      }}\n      onClick={(e) => {\n        if (e.target === e.currentTarget && !wasRecentDrag() && !wasRecentChipPress()) {\n          settings.onSlotClick?.({ date: dayStart, allDay: true, view }, e);\n        }\n      }}\n    >\n      {/* Only on the first cell of the selection, same rule as the month grid,\n          so a three-day all-day drag labels itself once rather than three\n          times. pointer-events-none keeps the cell's own pointerdown guard\n          (`e.target === e.currentTarget`) intact. */}\n      {inDraft?.isStart && allDayDraftRange && (\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-x-1 top-1/2 -translate-y-1/2\",\n            EVENT_CALENDAR_SLOT_DRAFT.label,\n          )}\n        >\n          {settings.i18n.functions.formatDayRange(\n            {\n              start: toZoned(allDayDraftRange.start, settings.timeZone),\n              end: toZoned(allDayDraftRange.end, settings.timeZone),\n            },\n            { locale: settings.locale },\n          )}\n        </span>\n      )}\n    </div>\n  );\n}\n\nfunction EventCalendarTimeGutter({\n  days,\n  slots,\n  startHour,\n  interval,\n}: {\n  days: Date[];\n  slots: number[];\n  startHour: number;\n  interval: number;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const referenceDay = days[0] ?? new Date();\n  const labelFormat =\n    interval % 60 === 0 ? settings.i18n.formats.timeGutter : settings.i18n.formats.timeGutterMinute;\n  return (\n    <div\n      data-slot=\"event-calendar-time-gutter\"\n      className={cn(\n        \"relative w-(--ec-gutter-width,4.5rem) shrink-0 border-e\",\n        viewConfig.classNames?.timeGutter,\n      )}\n    >\n      {slots.map((minutes) => {\n        const time = addMinutes(zonedStartOfDay(referenceDay, settings.timeZone), minutes);\n        // Always consult renderTimeGutterSlot (a consumer may label the first\n        // slot); only the DEFAULT label is suppressed at the day-start edge.\n        const label =\n          viewConfig.renderTimeGutterSlot?.({\n            time,\n            hour: Math.floor(minutes / 60),\n            minute: minutes % 60,\n          }) ??\n          (minutes > startHour * 60\n            ? format(time, labelFormat, { locale: settings.locale })\n            : null);\n        return (\n          <div\n            key={minutes}\n            className=\"relative\"\n            style={{ height: `calc(var(--ec-hour-height) * ${interval / 60})` }}\n          >\n            {label != null && (\n              <span\n                className={cn(\n                  \"text-muted-foreground absolute end-2.5 -top-2\",\n                  viewConfig.classNames?.timeGutterLabel,\n                )}\n              >\n                {label}\n              </span>\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\n/** Absolute overlay block positioned by minutes (ghosts + drafts). */\nfunction minuteBlockStyle(startMin: number, endMin: number, boundsStartMin: number): CSSProperties {\n  const top = (startMin - boundsStartMin) / 60;\n  const height = Math.max((endMin - startMin) / 60, 0.25);\n  return {\n    top: `calc(var(--ec-hour-height) * ${top})`,\n    height: `calc(var(--ec-hour-height) * ${height})`,\n  };\n}\n\nfunction EventCalendarDayColumn({\n  day,\n  startHour,\n  endHour,\n  interval,\n}: {\n  day: Date;\n  startHour: number;\n  endHour: number;\n  interval: number;\n}) {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const { effective } = useEventCalendarViewSettings();\n  const { view } = useEventCalendarViewContext();\n  const gestures = useEventCalendarGestures();\n  const { segments, isToday } = useEventCalendarDay(day);\n  const isOff = resolveOffDay(\n    day,\n    settings.timeZone,\n    effective.offDays\n      ? typeof viewConfig.offDays === \"object\"\n        ? viewConfig.offDays\n        : true\n      : false,\n    settings.weekendDays,\n  );\n  const offClassName =\n    (typeof viewConfig.offDays === \"object\" && viewConfig.offDays.className) || \"bg-muted/25\";\n\n  const timeZone = settings.timeZone;\n  const dayStart = zonedStartOfDay(day, timeZone);\n  const dayEnd = addDays(toZoned(dayStart, timeZone), 1);\n  const totalMinutes = getDayTotalMinutes(day, timeZone);\n  const boundsStartMin = startHour * 60;\n  const boundsEndMin = Math.min(endHour * 60, totalMinutes);\n  const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin);\n\n  // Minute window of a proposal intersecting THIS day, or null\n  const windowFor = (start: Date, end: Date): [number, number] | null => {\n    if (start >= dayEnd || end <= dayStart) return null;\n    const from = Math.max(\n      differenceInMinutes(start > dayStart ? start : dayStart, dayStart),\n      boundsStartMin,\n    );\n    const to = Math.min(differenceInMinutes(end < dayEnd ? end : dayEnd, dayStart), boundsEndMin);\n    return to > from ? [from, to] : null;\n  };\n\n  const dragGhost = useEventCalendarSelector<\n    unknown,\n    | (Pick<\n        EventCalendarDragState,\n        \"valid\" | \"kind\" | \"occurrence\" | \"proposedStart\" | \"proposedEnd\"\n      > & {\n        window: [number, number];\n        color?: string;\n        title: string;\n      })\n    | null\n  >(\n    (state) => {\n      const drag = state.drag;\n      if (!drag || drag.proposedDayGranular) return null;\n      const window = windowFor(drag.proposedStart, drag.proposedEnd);\n      if (!window) return null;\n      return {\n        valid: drag.valid,\n        kind: drag.kind,\n        occurrence: drag.occurrence,\n        proposedStart: drag.proposedStart,\n        proposedEnd: drag.proposedEnd,\n        window,\n        color: drag.occurrence.event.color,\n        title: drag.occurrence.event.title,\n      };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.window[0] === b.window[0] &&\n          a.window[1] === b.window[1] &&\n          a.valid === b.valid &&\n          a.kind === b.kind &&\n          a.proposedStart.getTime() === b.proposedStart.getTime() &&\n          a.proposedEnd.getTime() === b.proposedEnd.getTime()),\n    },\n  );\n\n  const draftWindow = useEventCalendarSelector<unknown, [number, number] | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft || draft.allDay) return null;\n      return windowFor(draft.start, draft.end);\n    },\n    {\n      isEqual: (a, b) => a === b || (a !== null && b !== null && a[0] === b[0] && a[1] === b[1]),\n    },\n  );\n\n  // The draft's true instants, kept separate from `draftWindow` above because\n  // that one is clipped to this column's day bounds - the readout has to show\n  // what the user actually drew, not the clipped remainder.\n  const draftRange = useEventCalendarSelector<unknown, { start: Date; end: Date } | null>(\n    (state) => {\n      const draft = state.slotDraft;\n      if (!draft || draft.allDay) return null;\n      return { start: draft.start, end: draft.end };\n    },\n    {\n      isEqual: (a, b) =>\n        a === b ||\n        (a !== null &&\n          b !== null &&\n          a.start.getTime() === b.start.getTime() &&\n          a.end.getTime() === b.end.getTime()),\n    },\n  );\n\n  // Segments the day bounds clip away still occupy a column in the shared\n  // index's packing, leaving a phantom empty half beside the first in-bounds\n  // chip. Repack the visible subset; clones keep the index cache untouched.\n  const packedTimed = useMemo(() => {\n    const visible = segments.timed.filter((segment) => {\n      const startMin = Math.max(segment.startMin ?? 0, boundsStartMin);\n      const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin);\n      return endMin > boundsStartMin && startMin < boundsEndMin;\n    });\n    if (visible.length === segments.timed.length) return segments.timed;\n    const clones = visible.map((segment) => ({ ...segment }) as EventCalendarSegment);\n    packTimedSegments(clones);\n    return clones;\n  }, [segments.timed, boundsStartMin, boundsEndMin]);\n\n  const slotFromPointer = (e: React.MouseEvent): { date: Date; end: Date } => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const pxPerMinute = rect.height / boundsMinutes;\n    const minutes = snapMinutes(\n      boundsStartMin + (e.clientY - rect.top) / pxPerMinute,\n      settings.snapDuration,\n    );\n    const clamped = Math.min(\n      Math.max(minutes, boundsStartMin),\n      boundsEndMin - settings.slotDuration,\n    );\n    return {\n      date: addMinutes(dayStart, clamped),\n      end: addMinutes(dayStart, clamped + settings.slotDuration),\n    };\n  };\n\n  return (\n    <div\n      data-slot=\"event-calendar-day-column\"\n      data-today={isToday || undefined}\n      data-off={isOff || undefined}\n      data-ec-day={dayStart.getTime()}\n      data-ec-bounds-start={boundsStartMin}\n      data-ec-bounds-end={boundsEndMin}\n      role=\"group\"\n      aria-label={format(toZoned(day, timeZone), settings.i18n.formats.dayAria, {\n        locale: settings.locale,\n      })}\n      className={cn(\n        \"relative min-w-0 border-e last:border-e-0\",\n        isOff && offClassName,\n        // time grid keeps today's column on the normal background (the header\n        // marks today); only a consumer todayClassName can tint it\n        isToday && viewConfig.todayClassName,\n        viewConfig.dayClassName?.(day),\n        viewConfig.classNames?.dayColumn,\n      )}\n      style={{\n        height: `calc(var(--ec-hour-height) * ${boundsMinutes / 60})`,\n        backgroundImage: `repeating-linear-gradient(to bottom, transparent, transparent calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--border)) calc(var(--ec-hour-height) * ${interval / 60}))`,\n      }}\n      onPointerDown={(e) => {\n        if (e.target === e.currentTarget) gestures.beginCreate(e, day, false);\n      }}\n      onClick={(e) => {\n        if (e.target !== e.currentTarget || wasRecentDrag() || wasRecentChipPress()) return;\n        const slot = slotFromPointer(e);\n        settings.onSlotClick?.({ ...slot, allDay: false, view }, e);\n      }}\n    >\n      {viewConfig.renderDayColumnBackground && (\n        <div className=\"pointer-events-none absolute inset-0\">\n          {viewConfig.renderDayColumnBackground({\n            day,\n            boundsStartMin,\n            boundsEndMin,\n            totalMinutes,\n          })}\n        </div>\n      )}\n      {packedTimed.map((segment) => {\n        const startMin = Math.max(segment.startMin ?? 0, boundsStartMin);\n        const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin);\n        if (endMin <= boundsStartMin || startMin >= boundsEndMin) return null;\n        const columnCount = segment.columnCount ?? 1;\n        const column = segment.column ?? 0;\n        const span = segment.columnSpan ?? 1;\n        const zIndex = segment.occurrence.event.zIndex ?? 10 + column;\n        // Strict side-by-side columns - no cascade overlap (fade-truncate +\n        // hover reveal carry the legibility); the ring separates neighbors.\n        const colPct = 100 / columnCount;\n        return (\n          <div\n            key={segment.occurrence.key}\n            // min-h keeps 15-min chips readable (Google-style: the block may\n            // slightly outgrow its true window); hover raises a squeezed\n            // cascade chip above its overlapping neighbors\n            className=\"absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40\"\n            style={\n              {\n                ...minuteBlockStyle(startMin, endMin, boundsStartMin),\n                left: `${column * colPct}%`,\n                width: `${span * colPct}%`,\n                \"--ec-z\": zIndex,\n              } as CSSProperties\n            }\n          >\n            <EventCalendarEvent\n              segment={segment}\n              className={cn(\n                columnCount > 1 && \"ring-background ring-1\",\n                // short chips: single centered row, exact-fit line height so\n                // the title never slices mid-glyph\n                endMin - startMin < viewConfig.compactEventMinutes\n                  ? \"h-full gap-1 py-0 leading-4\"\n                  : \"h-full flex-col items-start justify-start gap-0 py-1\",\n                viewConfig.classNames?.timedChip,\n              )}\n            />\n          </div>\n        );\n      })}\n      {/* Drag ghost, standardized (EVENT_CALENDAR_GHOST). Move: a faint\n          dashed placeholder at the snapped slot - the cursor-attached carry\n          clone owns the visual. Resize: the chip clone with a dashed boundary\n          at the proposed extent. Invalid: destructive marking. */}\n      {dragGhost && (\n        <div\n          data-slot=\"event-calendar-drag-ghost\"\n          data-kind={dragGhost.kind}\n          data-drop-invalid={!dragGhost.valid || undefined}\n          className={cn(\n            \"pointer-events-none absolute inset-x-0.5 z-50 min-h-(--ec-event-min-h,1.5rem)\",\n            dragGhost.kind === \"move\"\n              ? cn(EVENT_CALENDAR_GHOST.move, !dragGhost.valid && EVENT_CALENDAR_GHOST.invalid)\n              : cn(\n                  EVENT_CALENDAR_GHOST.resize,\n                  !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidResize,\n                ),\n            viewConfig.classNames?.dragGhost,\n          )}\n          style={\n            {\n              ...minuteBlockStyle(dragGhost.window[0], dragGhost.window[1], boundsStartMin),\n              \"--ec-event-color\": dragGhost.color ?? \"var(--primary)\",\n            } as CSSProperties\n          }\n        >\n          {dragGhost.kind !== \"move\" && (\n            <EventCalendarEvent\n              preview\n              segment={{\n                occurrence: {\n                  ...dragGhost.occurrence,\n                  start: dragGhost.proposedStart,\n                  end: dragGhost.proposedEnd,\n                  allDay: false,\n                },\n                day,\n                isStart: true,\n                isEnd: true,\n                continuesBefore: false,\n                continuesAfter: false,\n                startMin: dragGhost.window[0],\n                endMin: dragGhost.window[1],\n              }}\n              className={cn(\n                dragGhost.window[1] - dragGhost.window[0] < viewConfig.compactEventMinutes\n                  ? \"h-full gap-1 py-0 leading-4\"\n                  : \"h-full flex-col items-start justify-start gap-0 py-1\",\n                viewConfig.classNames?.timedChip,\n                \"inset-ring-0\",\n                !dragGhost.valid && EVENT_CALENDAR_GHOST.invalidContent,\n              )}\n            />\n          )}\n        </div>\n      )}\n      {/* Drag-create draft */}\n      {draftWindow && (\n        <div\n          data-slot=\"event-calendar-slot-draft\"\n          className={cn(\n            EVENT_CALENDAR_SLOT_DRAFT.box,\n            \"pointer-events-none absolute inset-x-0.5 z-40 overflow-hidden\",\n            viewConfig.classNames?.slotDraft,\n          )}\n          style={minuteBlockStyle(draftWindow[0], draftWindow[1], boundsStartMin)}\n        >\n          {/* Live range readout while dragging, the way Outlook and Google\n              Calendar do it: the entire point of the gesture is to pick a\n              time, so show the time being picked instead of an empty box.\n              Reuses `formatEventTime`, the same formatter the chips use, so\n              locale, time zone and 12/24-hour all match the rest of the\n              calendar for free. `truncate` plus the wrapper's overflow-hidden\n              keeps a 15-minute draft from spilling past its own border. */}\n          {draftRange && (\n            <span className={cn(\"block\", EVENT_CALENDAR_SLOT_DRAFT.label)}>\n              {settings.i18n.functions.formatEventTime(\n                toZoned(draftRange.start, timeZone),\n                toZoned(draftRange.end, timeZone),\n                false,\n                { locale: settings.locale },\n              )}\n            </span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction EventCalendarNowIndicator({\n  days,\n  startHour,\n  endHour,\n}: {\n  days: Date[];\n  startHour: number;\n  endHour: number;\n}): ReactNode {\n  const settings = useEventCalendarSettings();\n  const viewConfig = useEventCalendarViewConfig();\n  const now = useNow(viewConfig.nowIndicatorInterval);\n\n  const timeZone = settings.timeZone;\n  const todayKey = getDayKey(now, timeZone);\n  const todayIndex = days.findIndex((day) => getDayKey(day, timeZone) === todayKey);\n  if (todayIndex === -1) return null;\n\n  const dayStart = zonedStartOfDay(now, timeZone);\n  const minutes = differenceInMinutes(now, dayStart);\n  if (minutes < startHour * 60 || minutes > endHour * 60) return null;\n  const top = minutes / 60 - startHour;\n\n  if (viewConfig.renderNowIndicator) {\n    return (\n      <div\n        className=\"pointer-events-none absolute inset-x-0 z-40\"\n        style={{ top: `calc(var(--ec-hour-height) * ${top})` }}\n      >\n        {viewConfig.renderNowIndicator({ time: now })}\n      </div>\n    );\n  }\n\n  const columnWidthPct = 100 / days.length;\n\n  return (\n    <div\n      data-slot=\"event-calendar-now-indicator\"\n      className=\"pointer-events-none absolute inset-x-0 z-40\"\n      style={{ top: `calc(var(--ec-hour-height) * ${top})` }}\n    >\n      {/* hairline across the content columns only (clear of the time gutter) */}\n      <div className=\"bg-destructive/40 absolute start-(--ec-gutter-width,4.5rem) end-0 h-px\" />\n      {/* stronger segment + dot over today's column */}\n      <div\n        className=\"absolute h-px\"\n        style={{\n          left: `calc(var(--ec-gutter-width, 4.5rem) + (100% - var(--ec-gutter-width, 4.5rem)) * ${(todayIndex * columnWidthPct) / 100})`,\n          width: `calc((100% - var(--ec-gutter-width, 4.5rem)) * ${columnWidthPct / 100})`,\n        }}\n      >\n        <div className=\"bg-destructive absolute inset-x-0 top-0 h-px\" />\n        {/* dot leads the line at today's column-start border: pulled 1px left of\n            center (-start-1 = -4px vs the 6px/size-1.5 circle) so it reads as a\n            distinct bullet instead of merging into the line to its right */}\n        <div className=\"bg-destructive absolute -start-1 top-0 size-1.5 -translate-y-1/2 rounded-full\" />\n      </div>\n    </div>\n  );\n}\n\ntype TimeGridViewProps = Omit<EventCalendarTimeGridProps, \"view\">;\n\nfunction EventCalendarWeekView(props: TimeGridViewProps) {\n  return <EventCalendarTimeGrid view=\"week\" {...props} />;\n}\n\nfunction EventCalendarDayView(props: TimeGridViewProps) {\n  return <EventCalendarTimeGrid view=\"day\" {...props} />;\n}\n\nfunction EventCalendarDaysView(props: TimeGridViewProps) {\n  return <EventCalendarTimeGrid view=\"days\" {...props} />;\n}\n\nexport type { EventCalendarTimeGridProps };\nexport {\n  EventCalendarDaysView,\n  EventCalendarDayView,\n  EventCalendarNowIndicator,\n  EventCalendarTimeGrid,\n  EventCalendarTimeGutter,\n  EventCalendarWeekView,\n  minuteBlockStyle,\n  useNow,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-time-grid.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar-types.tsx",
      "content": "// Title: Event Calendar Types\n// Description: Public TypeScript contract for the headless event calendar: events, occurrences, segments, state, and callbacks.\n\ntype EventCalendarEventId = string;\n\ntype CalendarView = \"month\" | \"week\" | \"day\" | \"days\" | \"agenda\" | \"resource\";\n\n/** Bookable resource. The resource view flattens children and renders leaves\n *  only, as booking columns; a parent's own title never renders. */\ninterface EventCalendarResource {\n  id: string;\n  title: string;\n  color?: string;\n  children?: EventCalendarResource[];\n}\n\n/** Half-open: start is inclusive, end is exclusive. */\ninterface EventCalendarDateRange {\n  start: Date;\n  end: Date;\n}\n\ntype EventCalendarWeekday = \"MO\" | \"TU\" | \"WE\" | \"TH\" | \"FR\" | \"SA\" | \"SU\";\n\ninterface EventCalendarRecurrenceRule {\n  freq: \"daily\" | \"weekly\" | \"monthly\" | \"yearly\";\n  interval?: number;\n  count?: number;\n  /** Inclusive instant. */\n  until?: Date;\n  byWeekday?: Array<EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }>;\n  byMonthDay?: number[];\n  byMonth?: number[];\n  weekStart?: EventCalendarWeekday;\n  exDates?: Date[];\n  rDates?: Date[];\n}\n\ninterface CalendarEvent<TData = unknown> {\n  id: EventCalendarEventId;\n  title: string;\n  start: Date;\n  /** Exclusive; must be >= start. */\n  end: Date;\n  /** Start and end must be midnights in the display time zone: segmentation\n   *  walks raw instants, so another zone's midnight paints the wrong days. */\n  allDay?: boolean;\n  /** Structured rule or a raw \"RRULE:...\" line. */\n  recurrence?: EventCalendarRecurrenceRule | string;\n  /** This event is an edited single occurrence of that series. */\n  recurringEventId?: EventCalendarEventId;\n  /** Which occurrence it replaces (RECURRENCE-ID semantics). */\n  originalStart?: Date;\n  /** Token or css color; flows to the --ec-event-color css var. */\n  color?: string;\n  /** Excluded from drag and resize regardless of interactions state. */\n  readOnly?: boolean;\n  /** Per-event overrides; defaults come from interactions.drag / .resize. */\n  draggable?: boolean;\n  resizable?: boolean;\n  /** Packing prominence; feeds getEventPriority ordering. */\n  priority?: number;\n  /** Verbatim stacking override; replaces the computed 10 + column. */\n  zIndex?: number;\n  resourceId?: string;\n  data?: TData;\n}\n\ninterface EventCalendarOccurrence<TData = unknown> {\n  /** Stable per instance: `${event.id}::${startISO}`. */\n  key: string;\n  eventId: EventCalendarEventId;\n  event: CalendarEvent<TData>;\n  start: Date;\n  end: Date;\n  allDay: boolean;\n  isRecurring: boolean;\n  recurrenceIndex?: number;\n}\n\ninterface EventCalendarSegment<TData = unknown> {\n  occurrence: EventCalendarOccurrence<TData>;\n  /** Zoned midnight of the segment's day; startMin/endMin count from it. */\n  day: Date;\n  isStart: boolean;\n  isEnd: boolean;\n  continuesBefore: boolean;\n  continuesAfter: boolean;\n  /** Timed only: minutes from the zoned day start. */\n  startMin?: number;\n  endMin?: number;\n  /** Layout output: lane stacks month bars and all-day lanes;\n   *  column/columnCount/columnSpan pack time-grid overlaps;\n   *  rowIndex/colStart/colSpan place a bar in the week row. */\n  lane?: number;\n  column?: number;\n  columnCount?: number;\n  columnSpan?: number;\n  rowIndex?: number;\n  colStart?: number;\n  colSpan?: number;\n}\n\ninterface EventCalendarSelection {\n  eventKeys: string[];\n  /** Committed slot selection; EventCalendarSlotDraft holds the in-gesture one. */\n  slot: { start: Date; end: Date; allDay: boolean } | null;\n}\n\ninterface EventCalendarInteractions {\n  drag: boolean;\n  resize: boolean;\n  selectSlot: boolean;\n}\n\ninterface EventCalendarDragState<TData = unknown> {\n  kind: \"move\" | \"resize-start\" | \"resize-end\";\n  occurrence: EventCalendarOccurrence<TData>;\n  proposedStart: Date;\n  proposedEnd: Date;\n  proposedAllDay: boolean;\n  /** Day-granular proposal (month cells, all-day lane), not minute columns; the\n   *  all-day ghost keys on it: proposedAllDay misses timed MULTI-DAY bars. */\n  proposedDayGranular: boolean;\n  proposedResourceId?: string;\n  /** Last canDropEvent verdict; drives data-drop-invalid styling. */\n  valid: boolean;\n}\n\n/** The in-progress drag-create rectangle only; cleared on commit or cancel. */\ninterface EventCalendarSlotDraft {\n  start: Date;\n  end: Date;\n  allDay: boolean;\n  view: CalendarView;\n  resourceId?: string;\n}\n\n/** \"View settings\" toggles; undefined defers to the root view-config prop. */\ninterface EventCalendarViewSettings {\n  weekends?: boolean;\n  weekNumbers?: boolean;\n  nowIndicator?: boolean;\n  offDays?: boolean;\n}\n\ninterface EventCalendarState<TData = unknown> {\n  view: CalendarView;\n  /** Anchor date; navigation steps this, and both ranges derive from it. */\n  date: Date;\n  /** Read by the \"days\" view only; other views ignore it. */\n  dayCount: number;\n  /** Full rendered grid incl. outside days - fetch remote data for THIS. */\n  visibleRange: EventCalendarDateRange;\n  /** The logical period (the month/week itself). */\n  activeRange: EventCalendarDateRange;\n  events: CalendarEvent<TData>[];\n  selection: EventCalendarSelection;\n  interactions: EventCalendarInteractions;\n  loading: boolean;\n  drag: EventCalendarDragState<TData> | null;\n  slotDraft: EventCalendarSlotDraft | null;\n  viewSettings: EventCalendarViewSettings;\n}\n\ninterface EventCalendarRangeInfo {\n  range: EventCalendarDateRange;\n  activeRange: EventCalendarDateRange;\n  view: CalendarView;\n  date: Date;\n  timeZone: string;\n}\n\ninterface EventCalendarProposedUpdate<TData = unknown> {\n  event: CalendarEvent<TData>;\n  /** null when source === \"api\". */\n  occurrence: EventCalendarOccurrence<TData> | null;\n  start: Date;\n  end: Date;\n  allDay: boolean;\n  resourceId?: string;\n  source: \"drag\" | \"resize-start\" | \"resize-end\" | \"keyboard\" | \"api\";\n}\n\n/** false = reject/revert; void or true = accept; object = accept with adjustment. */\n// biome-ignore lint/suspicious/noConfusingVoidType: handlers may return nothing to accept; `undefined` would reject `() => {}`\ntype EventCalendarUpdateResult = boolean | void | { start?: Date; end?: Date; allDay?: boolean };\n\n/** A click is a point, not a range; `end` is present for timed slots. */\ninterface EventCalendarSlotInfo {\n  date: Date;\n  end?: Date;\n  allDay: boolean;\n  view: CalendarView;\n  resourceId?: string;\n}\n\n/** Off-days; `true` = weekend defaults. Marked cells carry `data-off`. */\ninterface EventCalendarOffDaysConfig {\n  /** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */\n  weekendDays?: number[];\n  /** Additional explicit off dates (compared by day in the display zone). */\n  dates?: Date[];\n  /** Full custom predicate; runs in addition to weekendDays/dates. */\n  isOffDay?: (day: Date) => boolean;\n  /** Marker classes; default \"bg-muted/25\". */\n  className?: string;\n}\n\ninterface EventCalendarDataAdapter<TData = unknown> {\n  getEvents(range: EventCalendarDateRange, signal?: AbortSignal): Promise<CalendarEvent<TData>[]>;\n}\n\nexport type {\n  CalendarEvent,\n  CalendarView,\n  EventCalendarDataAdapter,\n  EventCalendarDateRange,\n  EventCalendarDragState,\n  EventCalendarEventId,\n  EventCalendarInteractions,\n  EventCalendarOccurrence,\n  EventCalendarOffDaysConfig,\n  EventCalendarProposedUpdate,\n  EventCalendarRangeInfo,\n  EventCalendarRecurrenceRule,\n  EventCalendarResource,\n  EventCalendarSegment,\n  EventCalendarSelection,\n  EventCalendarSlotDraft,\n  EventCalendarSlotInfo,\n  EventCalendarState,\n  EventCalendarUpdateResult,\n  EventCalendarViewSettings,\n  EventCalendarWeekday,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar-types.tsx"
    },
    {
      "path": "src/blocks/event-calendar/event-calendar.tsx",
      "content": "\"use client\";\n\nimport type * as React from \"react\";\n\n/**\n * Headless-first event calendar with month, week, day, N-day, agenda, and\n * resource views; external CRUD contract via a subscribable store. Ported\n * from reui's @reui/event-calendar family — see docs/components-reference.md.\n */\n\nimport { cn } from \"cn\";\nimport {\n  type ComponentType,\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { addDays } from \"@/lib/date-fns-compat/index\";\nimport {\n  type EventCalendarI18nConfig,\n  type EventCalendarI18nOverrides,\n  mergeEventCalendarI18n,\n} from \"./event-calendar-i18n\";\nimport {\n  buildEventIndex,\n  defaultEventOrder,\n  type EventCalendarDayBucket,\n  type EventCalendarIndex,\n  eventsOverlap,\n  getDayKey,\n  getRangeKey,\n  getViewDateRange,\n  stepDate,\n  toZoned,\n  type WeekStartsOn,\n  zonedStartOfDay,\n} from \"./event-calendar-lib\";\nimport { mergeProps } from \"./event-calendar-merge-props\";\nimport type {\n  CalendarEvent,\n  CalendarView,\n  EventCalendarDateRange,\n  EventCalendarDragState,\n  EventCalendarEventId,\n  EventCalendarInteractions,\n  EventCalendarOccurrence,\n  EventCalendarOffDaysConfig,\n  EventCalendarProposedUpdate,\n  EventCalendarRangeInfo,\n  EventCalendarResource,\n  EventCalendarSegment,\n  EventCalendarSelection,\n  EventCalendarSlotDraft,\n  EventCalendarSlotInfo,\n  EventCalendarState,\n  EventCalendarUpdateResult,\n  EventCalendarViewSettings,\n} from \"./event-calendar-types\";\n\nconst BASE_VIEWS: CalendarView[] = [\"month\", \"week\", \"day\", \"days\", \"agenda\"];\nconst ALL_VIEWS: CalendarView[] = [...BASE_VIEWS, \"resource\"];\n\nconst DEFAULT_INTERACTIONS: EventCalendarInteractions = {\n  drag: true,\n  resize: true,\n  selectSlot: true,\n};\n\nconst EMPTY_SELECTION: EventCalendarSelection = { eventKeys: [], slot: null };\n\n// resolveSettings runs on every render, so a freshly allocated default would\n// bust the view memos keyed on it (the month grid rebuilds its 42 zoned day\n// starts whenever weekendDays changes identity). Shared, never mutated.\nconst DEFAULT_WEEKEND_DAYS: number[] = [0, 6];\nconst EMPTY_RESOURCES: EventCalendarResource[] = [];\nconst DEFAULT_EVENT_PRIORITY = (event: CalendarEvent<never>) => event.priority ?? 0;\n\n/**\n * `Intl.Locale.prototype.getWeekInfo()` (CLDR-backed) is newer than this\n * project's `tsconfig.json` `lib` target (`ES2022` + `DOM`, no\n * `ESNext.Intl`), so it isn't declared on the global `Intl.Locale` type here\n * - a narrow local shape instead of widening the whole project's `lib`\n * array for one call site.\n */\ninterface LocaleWithWeekInfo {\n  getWeekInfo?: () => { firstDay: number };\n}\n\n/**\n * Locale-aware weekStartsOn default, replacing date-fns's\n * `Locale.options.weekStartsOn` (gone now that `locale` is a plain BCP-47\n * string, not a date-fns Locale object). `getWeekInfo().firstDay` is ISO\n * (1 = Monday .. 7 = Sunday); `% 7` maps it to this codebase's date-fns-style\n * `WeekStartsOn` (0 = Sunday .. 6 = Saturday), e.g. Monday 1 -> 1, Sunday 7 ->\n * 0. Verified against this project's actual Node/ICU data: en-US, pt-BR, and\n * ja-JP all resolve to Sunday-first (0); de-DE, fr-FR, en-GB, and es-ES all\n * resolve to Monday-first (1).\n * Returns undefined (falls through to the caller's own default) for a\n * malformed BCP-47 tag (`new Intl.Locale` throws `RangeError`) or a runtime\n * that predates `getWeekInfo`.\n */\nfunction localeWeekStartsOn(locale: string): WeekStartsOn | undefined {\n  try {\n    const info = (new Intl.Locale(locale) as unknown as LocaleWithWeekInfo).getWeekInfo?.();\n    return info ? ((info.firstDay % 7) as WeekStartsOn) : undefined;\n  } catch {\n    return undefined;\n  }\n}\n\n/**\n * The user's own IANA time zone - same idiom `date-input`/`calendar`/\n * `date-picker` use elsewhere in this project\n * (`Intl.DateTimeFormat().resolvedOptions().timeZone`) - falling back to\n * `\"America/Sao_Paulo\"` only in an environment with no global `Intl` (e.g.\n * a restricted SSR context); a real browser/Node runtime always resolves a\n * valid zone on its own.\n */\nfunction resolveDefaultTimeZone(): string {\n  return typeof Intl !== \"undefined\"\n    ? Intl.DateTimeFormat().resolvedOptions().timeZone\n    : \"America/Sao_Paulo\";\n}\n\n/**\n * The user's own BCP-47 locale - same idiom `lib/date-fns-compat`'s\n * `getDefaultLocale()` uses - falling back to `\"pt-BR\"` (this project's\n * primary audience, matching `date-input`/`calendar`/`date-picker`'s own\n * hardcoded default) only in an environment with no global `Intl`.\n */\nfunction resolveDefaultLocale(): string {\n  return typeof Intl !== \"undefined\" ? Intl.DateTimeFormat().resolvedOptions().locale : \"pt-BR\";\n}\n\n// Priority-aware default order, cached on the resolver identity: eventOrder is\n// part of the index cache key, so a fresh closure per render would rebuild the\n// whole index on every render.\nconst priorityOrderCache = new WeakMap<object, unknown>();\ntype EventOrder<TData> = (\n  a: EventCalendarOccurrence<TData>,\n  b: EventCalendarOccurrence<TData>,\n) => number;\nfunction priorityEventOrder<TData>(\n  getEventPriority: (event: CalendarEvent<TData>) => number,\n): EventOrder<TData> {\n  const cached = priorityOrderCache.get(getEventPriority);\n  if (cached) return cached as EventOrder<TData>;\n  // higher priority packs and orders first; ties fall through to the\n  // start/duration/key default\n  const order: EventOrder<TData> = (a, b) =>\n    getEventPriority(b.event) - getEventPriority(a.event) || defaultEventOrder(a, b);\n  priorityOrderCache.set(getEventPriority, order);\n  return order;\n}\n\ninterface EventCalendarCallbacks<TData = unknown> {\n  onEventClick?: (occurrence: EventCalendarOccurrence<TData>, e: React.MouseEvent) => void;\n  onEventDoubleClick?: (occurrence: EventCalendarOccurrence<TData>, e: React.MouseEvent) => void;\n  onEventUpdate?: (update: EventCalendarProposedUpdate<TData>) => EventCalendarUpdateResult;\n  canDropEvent?: (update: EventCalendarProposedUpdate<TData>) => boolean;\n  /**\n   * A move/resize was attempted on an event that cannot be dragged (readOnly,\n   * per-event draggable/resizable false, or the interaction is off). Fires once\n   * per gesture when the pointer crosses the activation threshold; the calendar\n   * shows a not-allowed cursor for the duration but never picks the message -\n   * broadcast it here so the consumer can surface a custom one.\n   */\n  onDragBlocked?: (\n    occurrence: EventCalendarOccurrence<TData>,\n    info: {\n      gesture: \"move\" | \"resize\";\n      reason: \"readOnly\" | \"disabled\" | \"interactions-off\";\n    },\n  ) => void;\n  onSlotClick?: (slot: EventCalendarSlotInfo, e: React.MouseEvent) => void;\n  onSelectSlot?: (slot: EventCalendarSlotDraft) => void;\n  canSelectSlot?: (slot: EventCalendarSlotDraft) => boolean;\n  onRangeChange?: (info: EventCalendarRangeInfo) => void;\n  onViewChange?: (view: CalendarView) => void;\n  onDateChange?: (date: Date) => void;\n  onDayCountChange?: (count: number) => void;\n  onSelectionChange?: (selection: EventCalendarSelection) => void;\n  onInteractionsChange?: (interactions: EventCalendarInteractions) => void;\n  onViewSettingsChange?: (viewSettings: EventCalendarViewSettings) => void;\n  onEventsChange?: (events: CalendarEvent<TData>[]) => void;\n  onMoreClick?: (\n    day: Date,\n    segments: EventCalendarOccurrence<TData>[],\n    e: React.MouseEvent,\n    // biome-ignore lint/suspicious/noConfusingVoidType: return nothing to keep the default popover, false to skip it\n  ) => void | false;\n}\n\ninterface UseEventCalendarStateOptions<TData = unknown> extends EventCalendarCallbacks<TData> {\n  events?: CalendarEvent<TData>[];\n  defaultEvents?: CalendarEvent<TData>[];\n  view?: CalendarView;\n  defaultView?: CalendarView;\n  date?: Date;\n  defaultDate?: Date;\n  dayCount?: number;\n  defaultDayCount?: number;\n  selection?: EventCalendarSelection;\n  defaultSelection?: EventCalendarSelection;\n  interactions?: Partial<EventCalendarInteractions>;\n  defaultInteractions?: Partial<EventCalendarInteractions>;\n  viewSettings?: EventCalendarViewSettings;\n  defaultViewSettings?: EventCalendarViewSettings;\n  loading?: boolean;\n  views?: CalendarView[];\n  timeZone?: string;\n  locale?: string;\n  weekStartsOn?: WeekStartsOn;\n  dayStartHour?: number;\n  dayEndHour?: number;\n  slotDuration?: number;\n  snapDuration?: number;\n  agendaDayCount?: number;\n  fixedWeeks?: boolean;\n  showOutsideDays?: boolean;\n  i18n?: EventCalendarI18nOverrides;\n  /** Bookable resources for the resource view. */\n  resources?: EventCalendarResource[];\n  getEventPriority?: (event: CalendarEvent<TData>) => number;\n  eventOrder?: (a: EventCalendarOccurrence<TData>, b: EventCalendarOccurrence<TData>) => number;\n  getOccurrences?: (\n    event: CalendarEvent<TData>,\n    range: EventCalendarDateRange,\n    ctx: { timeZone: string },\n  ) => Array<{ start: Date; end: Date }> | null;\n  /**\n   * Weekday numbers (0 = Sunday) treated as the weekend by the \"weekends\"\n   * view toggle. @default [0, 6]\n   */\n  weekendDays?: number[];\n  /** Pointer-gesture tuning (activation distances, touch delay, autoscroll). */\n  activation?: Partial<EventCalendarActivationConfig>;\n}\n\ninterface EventCalendarActivationConfig {\n  moveDistancePx: number;\n  createDistancePx: number;\n  touchDelayMs: number;\n  touchTolerancePx: number;\n  autoScrollEdgePx: number;\n  autoScrollMaxStepPx: number;\n}\n\n/**\n * Resolved configuration: every UseEventCalendarStateOptions field except the\n * controlled/uncontrolled state pairs, with defaults applied and i18n merged.\n * Read via ref semantics - callback identity changes never re-render the grid.\n */\ninterface EventCalendarSettings<TData = unknown> extends EventCalendarCallbacks<TData> {\n  timeZone: string;\n  locale: string;\n  weekStartsOn: WeekStartsOn;\n  views: CalendarView[];\n  dayStartHour: number;\n  dayEndHour: number;\n  slotDuration: number;\n  snapDuration: number;\n  agendaDayCount: number;\n  fixedWeeks: boolean;\n  showOutsideDays: boolean;\n  i18n: EventCalendarI18nConfig;\n  resources: EventCalendarResource[];\n  weekendDays: number[];\n  activation?: Partial<EventCalendarActivationConfig>;\n  getEventPriority: (event: CalendarEvent<TData>) => number;\n  eventOrder: (a: EventCalendarOccurrence<TData>, b: EventCalendarOccurrence<TData>) => number;\n  getOccurrences?: (\n    event: CalendarEvent<TData>,\n    range: EventCalendarDateRange,\n    ctx: { timeZone: string },\n  ) => Array<{ start: Date; end: Date }> | null;\n}\n\n// Every member below is a plain, `this`-free function value handed out to\n// consumers (React callbacks, destructured returns from the public hooks) -\n// arrow-typed properties (not method shorthand) so referencing them without\n// calling them (e.g. `next: instance.api.next`) doesn't trip\n// @typescript-eslint/unbound-method, which otherwise assumes shorthand\n// methods may rely on `this`.\ninterface EventCalendarApi<TData = unknown> {\n  next: () => void;\n  prev: () => void;\n  today: () => void;\n  goTo: (date: Date) => void;\n  setView: (view: CalendarView, opts?: { dayCount?: number }) => void;\n  setDayCount: (count: number) => void;\n  getEvents: () => CalendarEvent<TData>[];\n  getEvent: (id: EventCalendarEventId) => CalendarEvent<TData> | undefined;\n  setEvents: (events: CalendarEvent<TData>[]) => void;\n  addEvent: (event: CalendarEvent<TData>) => void;\n  updateEvent: (id: EventCalendarEventId, patch: Partial<CalendarEvent<TData>>) => void;\n  removeEvent: (id: EventCalendarEventId) => void;\n  getOccurrences: (range?: EventCalendarDateRange) => EventCalendarOccurrence<TData>[];\n  getOccurrencesForDay: (day: Date) => EventCalendarOccurrence<TData>[];\n  findOverlapping: (candidate: {\n    start: Date;\n    end: Date;\n    excludeEventId?: string;\n  }) => EventCalendarOccurrence<TData>[];\n  select: (selection: Partial<EventCalendarSelection>) => void;\n  selectEvent: (key: string, opts?: { additive?: boolean }) => void;\n  clearSelection: () => void;\n  setInteractions: (patch: Partial<EventCalendarInteractions>) => void;\n  setViewSettings: (patch: EventCalendarViewSettings) => void;\n  getVisibleRange: () => EventCalendarDateRange;\n  getActiveRange: () => EventCalendarDateRange;\n  /** TZDate in the calendar's display time zone. */\n  toZoned: (date: Date) => Date;\n  /** number = minutes from the zoned day start; no-op outside time-grid views. */\n  scrollToTime: (time: Date | number) => void;\n}\n\n/** Cross-file plumbing for sibling view/interaction modules; not public API. */\ninterface EventCalendarInternals<TData = unknown> {\n  getIndex: () => EventCalendarIndex<TData>;\n  setDrag: (drag: EventCalendarDragState<TData> | null) => void;\n  setSlotDraft: (draft: EventCalendarSlotDraft | null) => void;\n  registerScrollHandler: (handler: ((time: Date | number) => void) | null) => void;\n  applyProposedUpdate: (\n    update: EventCalendarProposedUpdate<TData>,\n    extraPatch?: Partial<CalendarEvent<TData>>,\n  ) => boolean;\n  getSettingsVersion: () => number;\n  /** The rendered calendar root element, or null before mount. */\n  getRootEl: () => HTMLElement | null;\n  setRootEl: (el: HTMLElement | null) => void;\n}\n\ninterface EventCalendarInstance<TData = unknown> {\n  getState: () => EventCalendarState<TData>;\n  subscribe: (listener: () => void) => () => void;\n  api: EventCalendarApi<TData>;\n  settings: EventCalendarSettings<TData>;\n  internals: EventCalendarInternals<TData>;\n}\n\nfunction resolveSettings<TData>(\n  options: UseEventCalendarStateOptions<TData>,\n): EventCalendarSettings<TData> {\n  const {\n    // strip state pairs; the rest flows into settings\n    events: _e,\n    defaultEvents: _de,\n    view: _v,\n    defaultView: _dv,\n    date: _d,\n    defaultDate: _dd,\n    dayCount: _dc,\n    defaultDayCount: _ddc,\n    selection: _s,\n    defaultSelection: _ds,\n    interactions: _i,\n    defaultInteractions: _di,\n    viewSettings: _vs,\n    defaultViewSettings: _dvs,\n    loading: _l,\n    ...rest\n  } = options;\n  const getEventPriority =\n    options.getEventPriority ?? (DEFAULT_EVENT_PRIORITY as (event: CalendarEvent<TData>) => number);\n  // Resolved once and reused below (locale feeds both the settings field and\n  // the weekStartsOn derivation) rather than re-querying Intl twice.\n  const resolvedLocale = options.locale ?? resolveDefaultLocale();\n  return {\n    ...rest,\n    timeZone: options.timeZone ?? resolveDefaultTimeZone(),\n    locale: resolvedLocale,\n    // locale-first default: de/fr-class locales get their CLDR\n    // first-day-of-week (see localeWeekStartsOn) without the caller also\n    // having to pass weekStartsOn explicitly; an explicit weekStartsOn\n    // always wins. `resolvedLocale` above is never absent (an unpassed\n    // locale still resolves to the user's own, or \"pt-BR\"), so this now\n    // reflects the actual effective locale rather than only an\n    // explicitly-passed one.\n    weekStartsOn: options.weekStartsOn ?? localeWeekStartsOn(resolvedLocale) ?? 0,\n    // the resource view only makes sense with resources configured\n    views: options.views ?? (options.resources?.length ? ALL_VIEWS : BASE_VIEWS),\n    dayStartHour: options.dayStartHour ?? 0,\n    dayEndHour: options.dayEndHour ?? 24,\n    slotDuration: options.slotDuration ?? 30,\n    snapDuration: options.snapDuration ?? 15,\n    agendaDayCount: options.agendaDayCount ?? 30,\n    fixedWeeks: options.fixedWeeks ?? true,\n    showOutsideDays: options.showOutsideDays ?? true,\n    i18n: mergeEventCalendarI18n(options.i18n),\n    resources: options.resources ?? EMPTY_RESOURCES,\n    getEventPriority,\n    eventOrder: options.eventOrder ?? priorityEventOrder(getEventPriority),\n    getOccurrences: options.getOccurrences,\n    weekendDays: options.weekendDays ?? DEFAULT_WEEKEND_DAYS,\n    activation: options.activation,\n  };\n}\n\n// Bundlers replace process.env.NODE_ENV at build time; declared here so\n// consumers don't need @types/node just for this check.\ndeclare const process: { env: { NODE_ENV?: string } };\n\nconst warned = new Set<string>();\nfunction warnOnce(key: string, message: string) {\n  if (process.env.NODE_ENV !== \"production\" && !warned.has(key)) {\n    warned.add(key);\n    console.warn(`[event-calendar] ${message}`);\n  }\n}\n\ninterface EventCalendarStore<TData> {\n  instance: EventCalendarInstance<TData>;\n  setOptions(next: UseEventCalendarStateOptions<TData>): boolean;\n  notify(): void;\n  emitRangeIfChanged(): void;\n}\n\nfunction createEventCalendarStore<TData>(\n  initial: UseEventCalendarStateOptions<TData>,\n): EventCalendarStore<TData> {\n  let options = initial;\n  let settings = resolveSettings(initial);\n  let settingsVersion = 0;\n\n  const listeners = new Set<() => void>();\n\n  const resolveView = (view: CalendarView): CalendarView => {\n    if (settings.views.includes(view)) return view;\n    const fallback = settings.views[0] ?? \"month\";\n    warnOnce(\n      `view-${view}`,\n      `view \"${view}\" is not in views [${settings.views.join(\", \")}]; falling back to \"${fallback}\".`,\n    );\n    return fallback;\n  };\n\n  const internal = {\n    view: resolveView(initial.defaultView ?? \"month\"),\n    date: initial.defaultDate ?? new Date(),\n    dayCount: initial.defaultDayCount ?? 3,\n    events: initial.defaultEvents ?? [],\n    selection: initial.defaultSelection ?? EMPTY_SELECTION,\n    interactions: { ...DEFAULT_INTERACTIONS, ...initial.defaultInteractions },\n    viewSettings: initial.defaultViewSettings ?? {},\n    drag: null as EventCalendarDragState<TData> | null,\n    slotDraft: null as EventCalendarSlotDraft | null,\n  };\n\n  let snapshot: EventCalendarState<TData> | null = null;\n  let indexCache: {\n    events: CalendarEvent<TData>[];\n    rangeKey: string;\n    timeZone: string;\n    weekStartsOn: WeekStartsOn;\n    eventOrder: EventCalendarSettings<TData>[\"eventOrder\"];\n    getOccurrences: EventCalendarSettings<TData>[\"getOccurrences\"];\n    index: EventCalendarIndex<TData>;\n  } | null = null;\n  // Second single-entry cache, for api.getOccurrences(range) outside the\n  // visible range: that branch builds a throwaway index, so without it every\n  // call hands back brand new occurrence OBJECTS and the identity-based\n  // isEqual in useEventCalendarOccurrences can never settle - an unstable\n  // getSnapshot is a hard render loop under useSyncExternalStore, not a slow\n  // render.\n  let rangeCache: {\n    events: CalendarEvent<TData>[];\n    rangeKey: string;\n    timeZone: string;\n    weekStartsOn: WeekStartsOn;\n    eventOrder: EventCalendarSettings<TData>[\"eventOrder\"];\n    getOccurrences: EventCalendarSettings<TData>[\"getOccurrences\"];\n    occurrences: EventCalendarOccurrence<TData>[];\n  } | null = null;\n  let scrollHandler: ((time: Date | number) => void) | null = null;\n  // The rendered calendar root element, registered by the <EventCalendar> host.\n  // The drag engine falls back to it to find day cells when a gesture starts\n  // from a portaled surface (e.g. a chip inside the \"+N more\" popover), whose\n  // DOM ancestors do not include the calendar.\n  let rootEl: HTMLElement | null = null;\n  let lastEmittedRangeKey: string | null = null;\n\n  // Controlled interactions merge, cached on input identity: rebuilding the\n  // merged object per snapshot would break Object.is for selector hooks.\n  let interactionsCache: {\n    input: Partial<EventCalendarInteractions>;\n    merged: EventCalendarInteractions;\n  } | null = null;\n  const mergedInteractions = (\n    input: Partial<EventCalendarInteractions>,\n  ): EventCalendarInteractions => {\n    if (interactionsCache?.input !== input) {\n      interactionsCache = {\n        input,\n        merged: { ...DEFAULT_INTERACTIONS, ...input },\n      };\n    }\n    return interactionsCache.merged;\n  };\n\n  const invalidate = () => {\n    snapshot = null;\n  };\n\n  const notify = () => {\n    for (const listener of listeners) listener();\n    emitRangeIfChanged();\n  };\n\n  const getState = (): EventCalendarState<TData> => {\n    if (snapshot) return snapshot;\n    const view = resolveView(options.view ?? internal.view);\n    const date = options.date ?? internal.date;\n    const dayCount = Math.max(1, options.dayCount ?? internal.dayCount);\n    const { visibleRange, activeRange } = getViewDateRange(view, date, {\n      timeZone: settings.timeZone,\n      weekStartsOn: settings.weekStartsOn,\n      dayCount,\n      agendaDayCount: settings.agendaDayCount,\n      fixedWeeks: settings.fixedWeeks,\n    });\n    snapshot = {\n      view,\n      date,\n      dayCount,\n      visibleRange,\n      activeRange,\n      events: options.events ?? internal.events,\n      selection: options.selection ?? internal.selection,\n      interactions: options.interactions\n        ? mergedInteractions(options.interactions)\n        : internal.interactions,\n      loading: options.loading ?? false,\n      drag: internal.drag,\n      slotDraft: internal.slotDraft,\n      viewSettings: options.viewSettings ?? internal.viewSettings,\n    };\n    return snapshot;\n  };\n\n  const emitRangeIfChanged = () => {\n    if (!settings.onRangeChange) return;\n    const state = getState();\n    const key = `${state.view}:${getRangeKey(state.visibleRange)}:${settings.timeZone}`;\n    if (key === lastEmittedRangeKey) return;\n    lastEmittedRangeKey = key;\n    settings.onRangeChange({\n      range: state.visibleRange,\n      activeRange: state.activeRange,\n      view: state.view,\n      date: state.date,\n      timeZone: settings.timeZone,\n    });\n  };\n\n  type ControlledKey =\n    | \"view\"\n    | \"date\"\n    | \"dayCount\"\n    | \"events\"\n    | \"selection\"\n    | \"interactions\"\n    | \"viewSettings\";\n\n  const setField = <K extends ControlledKey>(\n    key: K,\n    value: EventCalendarState<TData>[K extends \"events\" ? \"events\" : K],\n  ) => {\n    const controlled = options[key] !== undefined;\n    if (!controlled) {\n      // biome-ignore lint/suspicious/noExplicitAny: keyed write across the state's value types\n      (internal as any)[key] = value;\n      invalidate();\n    }\n    const callbacks: Record<ControlledKey, ((v: never) => void) | undefined> = {\n      view: settings.onViewChange as never,\n      date: settings.onDateChange as never,\n      dayCount: settings.onDayCountChange as never,\n      events: settings.onEventsChange as never,\n      selection: settings.onSelectionChange as never,\n      interactions: settings.onInteractionsChange as never,\n      viewSettings: settings.onViewSettingsChange as never,\n    };\n    callbacks[key]?.(value as never);\n    if (!controlled) notify();\n  };\n\n  // An occurrence key encodes the start instant (id::startISO), so committing a\n  // move re-keys the occurrence and a selection holding the old key would point\n  // at nothing. Remapped in the same commit and emitted BEFORE the events write\n  // so a controlled consumer applies the two in a consistent order.\n  const remapSelectionKey = (id: EventCalendarEventId, oldKey: string, nextStart: Date) => {\n    const newKey = `${id}::${nextStart.toISOString()}`;\n    if (newKey === oldKey) return;\n    const selection = getState().selection;\n    if (!selection.eventKeys.includes(oldKey)) return;\n    setField(\"selection\", {\n      ...selection,\n      eventKeys: selection.eventKeys.map((key) => (key === oldKey ? newKey : key)),\n    });\n  };\n\n  // extraPatch: non-timing fields committed in the SAME write. Two sequential\n  // setField(\"events\") calls break controlled mode - the second one re-reads\n  // the still-stale controlled array and its onEventsChange payload silently\n  // reverts the timing change the first one emitted.\n  const applyProposedUpdate = (\n    update: EventCalendarProposedUpdate<TData>,\n    extraPatch?: Partial<CalendarEvent<TData>>,\n  ): boolean => {\n    const result = settings.onEventUpdate?.(update);\n    if (result === false) return false;\n    const adjusted: Partial<CalendarEvent<TData>> =\n      result && typeof result === \"object\"\n        ? {\n            start: result.start ?? update.start,\n            end: result.end ?? update.end,\n            allDay: result.allDay ?? update.allDay,\n          }\n        : { start: update.start, end: update.end, allDay: update.allDay };\n    if (update.resourceId !== undefined) adjusted.resourceId = update.resourceId;\n    // the STORED event holds the pre-commit start, which is what the live\n    // occurrence key was built from (update.event already carries the proposal\n    // when the call comes from api.updateEvent)\n    const stored = getState().events.find((event) => event.id === update.event.id);\n    const oldKey =\n      update.occurrence?.key ?? (stored ? `${stored.id}::${stored.start.toISOString()}` : null);\n    if (oldKey) {\n      remapSelectionKey(update.event.id, oldKey, adjusted.start ?? update.start);\n    }\n    const events = getState().events;\n    const next = events.map((event) =>\n      event.id === update.event.id ? { ...event, ...extraPatch, ...adjusted } : event,\n    );\n    setField(\"events\", next);\n    return true;\n  };\n\n  const getIndex = (): EventCalendarIndex<TData> => {\n    const state = getState();\n    const rangeKey = getRangeKey(state.visibleRange);\n    if (\n      indexCache &&\n      indexCache.events === state.events &&\n      indexCache.rangeKey === rangeKey &&\n      indexCache.timeZone === settings.timeZone &&\n      indexCache.weekStartsOn === settings.weekStartsOn &&\n      indexCache.eventOrder === settings.eventOrder &&\n      indexCache.getOccurrences === settings.getOccurrences\n    ) {\n      return indexCache.index;\n    }\n    const index = buildEventIndex(state.events, state.visibleRange, {\n      timeZone: settings.timeZone,\n      weekStartsOn: settings.weekStartsOn,\n      eventOrder: settings.eventOrder,\n      getOccurrences: settings.getOccurrences,\n    });\n    indexCache = {\n      events: state.events,\n      rangeKey,\n      timeZone: settings.timeZone,\n      weekStartsOn: settings.weekStartsOn,\n      eventOrder: settings.eventOrder,\n      getOccurrences: settings.getOccurrences,\n      index,\n    };\n    return index;\n  };\n\n  const api: EventCalendarApi<TData> = {\n    next() {\n      const state = getState();\n      setField(\n        \"date\",\n        stepDate(state.view, state.date, 1, {\n          timeZone: settings.timeZone,\n          dayCount: state.dayCount,\n          agendaDayCount: settings.agendaDayCount,\n        }),\n      );\n    },\n    prev() {\n      const state = getState();\n      setField(\n        \"date\",\n        stepDate(state.view, state.date, -1, {\n          timeZone: settings.timeZone,\n          dayCount: state.dayCount,\n          agendaDayCount: settings.agendaDayCount,\n        }),\n      );\n    },\n    today() {\n      setField(\"date\", new Date());\n    },\n    goTo(date) {\n      setField(\"date\", date);\n    },\n    setView(view, opts) {\n      if (opts?.dayCount !== undefined) {\n        setField(\"dayCount\", Math.max(1, opts.dayCount));\n      }\n      setField(\"view\", resolveView(view));\n    },\n    setDayCount(count) {\n      setField(\"dayCount\", Math.max(1, count));\n    },\n    getEvents() {\n      return getState().events;\n    },\n    getEvent(id) {\n      return getState().events.find((event) => event.id === id);\n    },\n    setEvents(events) {\n      setField(\"events\", events);\n    },\n    addEvent(event) {\n      setField(\"events\", [...getState().events, event]);\n    },\n    updateEvent(id, patch) {\n      const event = api.getEvent(id);\n      if (!event) return;\n      const merged = { ...event, ...patch };\n      const timingChanged =\n        patch.start !== undefined || patch.end !== undefined || patch.allDay !== undefined;\n      if (timingChanged && settings.onEventUpdate) {\n        // single write: the non-timing rest rides along as extraPatch so\n        // controlled mode sees one consistent onEventsChange payload\n        const rest = { ...patch };\n        delete rest.start;\n        delete rest.end;\n        delete rest.allDay;\n        applyProposedUpdate(\n          {\n            event: merged,\n            occurrence: null,\n            start: merged.start,\n            end: merged.end,\n            allDay: merged.allDay ?? false,\n            source: \"api\",\n          },\n          rest,\n        );\n        return;\n      }\n      if (timingChanged) {\n        remapSelectionKey(id, `${id}::${event.start.toISOString()}`, merged.start);\n      }\n      setField(\n        \"events\",\n        getState().events.map((e) => (e.id === id ? merged : e)),\n      );\n    },\n    removeEvent(id) {\n      setField(\n        \"events\",\n        getState().events.filter((event) => event.id !== id),\n      );\n    },\n    getOccurrences(range) {\n      if (!range) return getIndex().occurrences;\n      const state = getState();\n      const within = range.start >= state.visibleRange.start && range.end <= state.visibleRange.end;\n      if (within) {\n        return getIndex().occurrences.filter((occ) => eventsOverlap(occ, range));\n      }\n      const rangeKey = getRangeKey(range);\n      if (\n        rangeCache &&\n        rangeCache.events === state.events &&\n        rangeCache.rangeKey === rangeKey &&\n        rangeCache.timeZone === settings.timeZone &&\n        rangeCache.weekStartsOn === settings.weekStartsOn &&\n        rangeCache.eventOrder === settings.eventOrder &&\n        rangeCache.getOccurrences === settings.getOccurrences\n      ) {\n        return rangeCache.occurrences;\n      }\n      const { occurrences } = buildEventIndex(state.events, range, {\n        timeZone: settings.timeZone,\n        weekStartsOn: settings.weekStartsOn,\n        eventOrder: settings.eventOrder,\n        getOccurrences: settings.getOccurrences,\n      });\n      rangeCache = {\n        events: state.events,\n        rangeKey,\n        timeZone: settings.timeZone,\n        weekStartsOn: settings.weekStartsOn,\n        eventOrder: settings.eventOrder,\n        getOccurrences: settings.getOccurrences,\n        occurrences,\n      };\n      return occurrences;\n    },\n    getOccurrencesForDay(day) {\n      const bucket = getIndex().byDay.get(getDayKey(day, settings.timeZone));\n      if (!bucket) return [];\n      const seen = new Set<string>();\n      const result: EventCalendarOccurrence<TData>[] = [];\n      for (const seg of [...bucket.allDay, ...bucket.timed]) {\n        if (seen.has(seg.occurrence.key)) continue;\n        seen.add(seg.occurrence.key);\n        result.push(seg.occurrence);\n      }\n      return result;\n    },\n    findOverlapping({ start, end, excludeEventId }) {\n      return api.getOccurrences({ start, end }).filter((occ) => occ.eventId !== excludeEventId);\n    },\n    select(partial) {\n      const current = getState().selection;\n      setField(\"selection\", {\n        eventKeys: partial.eventKeys ?? current.eventKeys,\n        slot: partial.slot !== undefined ? partial.slot : current.slot,\n      });\n    },\n    selectEvent(key, opts) {\n      const current = getState().selection;\n      const eventKeys = opts?.additive\n        ? current.eventKeys.includes(key)\n          ? current.eventKeys.filter((k) => k !== key)\n          : [...current.eventKeys, key]\n        : [key];\n      setField(\"selection\", { ...current, eventKeys });\n    },\n    clearSelection() {\n      setField(\"selection\", EMPTY_SELECTION);\n    },\n    setInteractions(patch) {\n      setField(\"interactions\", { ...getState().interactions, ...patch });\n    },\n    setViewSettings(patch) {\n      setField(\"viewSettings\", { ...getState().viewSettings, ...patch });\n    },\n    getVisibleRange() {\n      return getState().visibleRange;\n    },\n    getActiveRange() {\n      return getState().activeRange;\n    },\n    toZoned(date) {\n      return toZoned(date, settings.timeZone);\n    },\n    scrollToTime(time) {\n      scrollHandler?.(time);\n    },\n  };\n\n  const internals: EventCalendarInternals<TData> = {\n    getIndex,\n    setDrag(drag) {\n      internal.drag = drag;\n      invalidate();\n      notify();\n    },\n    setSlotDraft(draft) {\n      internal.slotDraft = draft;\n      invalidate();\n      notify();\n    },\n    registerScrollHandler(handler) {\n      scrollHandler = handler;\n    },\n    applyProposedUpdate,\n    getSettingsVersion() {\n      return settingsVersion;\n    },\n    getRootEl() {\n      return rootEl;\n    },\n    setRootEl(el) {\n      rootEl = el;\n    },\n  };\n\n  const instance: EventCalendarInstance<TData> = {\n    getState,\n    subscribe(listener) {\n      listeners.add(listener);\n      return () => listeners.delete(listener);\n    },\n    api,\n    get settings() {\n      return settings;\n    },\n    internals,\n  };\n\n  const STATE_KEYS = [\n    \"events\",\n    \"view\",\n    \"date\",\n    \"dayCount\",\n    \"selection\",\n    \"interactions\",\n    \"viewSettings\",\n    \"loading\",\n  ] as const;\n  const SETTINGS_KEYS = [\n    \"timeZone\",\n    \"locale\",\n    \"weekStartsOn\",\n    \"views\",\n    \"dayStartHour\",\n    \"dayEndHour\",\n    \"slotDuration\",\n    \"snapDuration\",\n    \"agendaDayCount\",\n    \"fixedWeeks\",\n    \"showOutsideDays\",\n    \"i18n\",\n    \"resources\",\n    \"getEventPriority\",\n    \"eventOrder\",\n    \"getOccurrences\",\n    \"weekendDays\",\n    \"activation\",\n  ] as const;\n\n  return {\n    instance,\n    setOptions(next) {\n      const prev = options;\n      options = next;\n      let changed = false;\n      for (const key of STATE_KEYS) {\n        if (prev[key] !== next[key]) {\n          changed = true;\n          break;\n        }\n      }\n      let settingsChanged = false;\n      for (const key of SETTINGS_KEYS) {\n        if (prev[key] !== next[key]) {\n          settingsChanged = true;\n          break;\n        }\n      }\n      settings = resolveSettings(next);\n      if (settingsChanged) {\n        settingsVersion++;\n        changed = true;\n      }\n      if (changed) invalidate();\n      return changed;\n    },\n    notify,\n    emitRangeIfChanged,\n  };\n}\n\n/**\n * Headless root hook - the full calendar engine without any markup.\n * Pass the returned instance to <EventCalendar calendar={instance}> or drive\n * fully custom UI from instance.getState()/subscribe/api.\n */\nfunction useEventCalendarState<TData = unknown>(\n  options: UseEventCalendarStateOptions<TData> = {},\n): EventCalendarInstance<TData> {\n  const [store] = useState(() => createEventCalendarStore<TData>(options));\n  const changed = store.setOptions(options);\n  const changedRef = useRef(false);\n  if (changed) changedRef.current = true;\n  useLayoutEffect(() => {\n    if (changedRef.current) {\n      changedRef.current = false;\n      store.notify();\n    }\n  });\n  // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only, onRangeChange fires once for the initial range\n  useEffect(() => {\n    store.emitRangeIfChanged();\n  }, []);\n  return store.instance;\n}\n\nconst EventCalendarContext =\n  // biome-ignore lint/suspicious/noExplicitAny: shared across every TData\n  createContext<EventCalendarInstance<any> | null>(null);\n\n/** The stable calendar instance; throws outside <EventCalendar>. */\nfunction useEventCalendar<TData = unknown>(): EventCalendarInstance<TData> {\n  const instance = useContext(EventCalendarContext);\n  if (!instance) {\n    throw new Error(\"useEventCalendar must be used within <EventCalendar>\");\n  }\n  return instance as EventCalendarInstance<TData>;\n}\n\ninterface UseEventCalendarSelectorOptions<TData, TSelected> {\n  calendar?: EventCalendarInstance<TData>;\n  isEqual?: (a: TSelected, b: TSelected) => boolean;\n}\n\n/** Fine-grained subscription with equality memoization (Object.is default). */\nfunction useEventCalendarSelector<TData = unknown, TSelected = unknown>(\n  selector: (state: EventCalendarState<TData>) => TSelected,\n  options?: UseEventCalendarSelectorOptions<TData, TSelected>,\n): TSelected {\n  const contextInstance = useContext(EventCalendarContext);\n  const instance = options?.calendar ?? contextInstance;\n  if (!instance) {\n    throw new Error(\n      \"useEventCalendarSelector needs an <EventCalendar> ancestor or an explicit `calendar` option\",\n    );\n  }\n  const isEqual = options?.isEqual ?? Object.is;\n  const lastRef = useRef<{ value: TSelected } | null>(null);\n  const selectorRef = useRef(selector);\n  selectorRef.current = selector;\n\n  const getSnapshot = () => {\n    const next = selectorRef.current(instance.getState() as EventCalendarState<TData>);\n    if (lastRef.current && isEqual(lastRef.current.value, next)) {\n      return lastRef.current.value;\n    }\n    lastRef.current = { value: next };\n    return next;\n  };\n\n  return useSyncExternalStore(instance.subscribe, getSnapshot, getSnapshot);\n}\n\nfunction useEventCalendarView(): {\n  view: CalendarView;\n  dayCount: number;\n  /** The resolved `views` option from settings. */\n  availableViews: CalendarView[];\n  setView: (view: CalendarView, opts?: { dayCount?: number }) => void;\n} {\n  const instance = useEventCalendar();\n  const view = useEventCalendarSelector((state) => state.view);\n  const dayCount = useEventCalendarSelector((state) => state.dayCount);\n  useEventCalendarSettingsVersion(instance);\n  return {\n    view,\n    dayCount,\n    availableViews: instance.settings.views,\n    setView: instance.api.setView,\n  };\n}\n\n/**\n * isToday is a wall-clock read, so no store write ever invalidates it and a\n * calendar left open overnight keeps highlighting yesterday. One timer per\n * display zone, armed for the next zoned midnight and re-armed on fire, wakes\n * every day-scoped hook exactly when the answer changes; an interval would\n * tick thousands of times a day to catch one transition.\n */\nconst midnightTicker = (() => {\n  const zones = new Map<\n    string,\n    { listeners: Set<() => void>; timer: ReturnType<typeof setTimeout> | null }\n  >();\n  let version = 0;\n\n  const arm = (timeZone: string) => {\n    const entry = zones.get(timeZone);\n    if (!entry) return;\n    const now = new Date();\n    const next = zonedStartOfDay(addDays(toZoned(now, timeZone), 1), timeZone).getTime();\n    entry.timer = setTimeout(\n      () => {\n        version++;\n        for (const listener of entry.listeners) listener();\n        arm(timeZone);\n      },\n      Math.max(1000, next - now.getTime()),\n    );\n  };\n\n  return {\n    subscribe(timeZone: string, listener: () => void) {\n      let entry = zones.get(timeZone);\n      if (!entry) {\n        entry = { listeners: new Set(), timer: null };\n        zones.set(timeZone, entry);\n        entry.listeners.add(listener);\n        arm(timeZone);\n      } else {\n        entry.listeners.add(listener);\n      }\n      const current = entry;\n      return () => {\n        current.listeners.delete(listener);\n        if (current.listeners.size > 0) return;\n        if (current.timer) clearTimeout(current.timer);\n        zones.delete(timeZone);\n      };\n    },\n    getVersion: () => version,\n  };\n})();\n\n/** Re-renders the caller on the next midnight in `timeZone`. */\nfunction useMidnightTick(timeZone: string): number {\n  const subscribe = useCallback(\n    (listener: () => void) => midnightTicker.subscribe(timeZone, listener),\n    [timeZone],\n  );\n  return useSyncExternalStore(subscribe, midnightTicker.getVersion, midnightTicker.getVersion);\n}\n\nfunction useEventCalendarNavigation(): {\n  date: Date;\n  /** i18n.functions.formatTitle output for the current view. */\n  title: string;\n  visibleRange: EventCalendarDateRange;\n  activeRange: EventCalendarDateRange;\n  next: () => void;\n  prev: () => void;\n  today: () => void;\n  goTo: (date: Date) => void;\n  /** True when the anchor period contains now in the display time zone. */\n  isToday: boolean;\n} {\n  const instance = useEventCalendar();\n  const { settings } = instance;\n  const slice = useEventCalendarSelector(\n    (state) => ({\n      date: state.date,\n      view: state.view,\n      visibleRange: state.visibleRange,\n      activeRange: state.activeRange,\n    }),\n    {\n      isEqual: (a, b) =>\n        a.date.getTime() === b.date.getTime() &&\n        a.view === b.view &&\n        getRangeKey(a.visibleRange) === getRangeKey(b.visibleRange),\n    },\n  );\n  useEventCalendarSettingsVersion(instance);\n  useMidnightTick(settings.timeZone);\n  const now = new Date();\n  return {\n    date: slice.date,\n    title: settings.i18n.functions.formatTitle(slice.view, {\n      date: toZoned(slice.date, settings.timeZone),\n      activeRange: slice.activeRange,\n      visibleRange: slice.visibleRange,\n      locale: settings.locale,\n    }),\n    visibleRange: slice.visibleRange,\n    activeRange: slice.activeRange,\n    next: instance.api.next,\n    prev: instance.api.prev,\n    today: instance.api.today,\n    goTo: instance.api.goTo,\n    isToday: now >= slice.activeRange.start && now < slice.activeRange.end,\n  };\n}\n\nfunction useEventCalendarSelection(): {\n  selection: EventCalendarSelection;\n  select: (selection: Partial<EventCalendarSelection>) => void;\n  selectEvent: (key: string, opts?: { additive?: boolean }) => void;\n  clearSelection: () => void;\n} {\n  const instance = useEventCalendar();\n  const selection = useEventCalendarSelector((state) => state.selection);\n  return {\n    selection,\n    select: instance.api.select,\n    selectEvent: instance.api.selectEvent,\n    clearSelection: instance.api.clearSelection,\n  };\n}\n\nfunction useEventCalendarInteractions(): {\n  interactions: EventCalendarInteractions;\n  setInteractions: (patch: Partial<EventCalendarInteractions>) => void;\n} {\n  const instance = useEventCalendar();\n  const interactions = useEventCalendarSelector((state) => state.interactions);\n  return { interactions, setInteractions: instance.api.setInteractions };\n}\n\n/** Expanded, sorted occurrences; defaults to the visible range. */\nfunction useEventCalendarOccurrences<TData = unknown>(\n  range?: EventCalendarDateRange,\n): EventCalendarOccurrence<TData>[] {\n  const instance = useEventCalendar<TData>();\n  return useEventCalendarSelector<TData, EventCalendarOccurrence<TData>[]>(\n    () => instance.api.getOccurrences(range),\n    {\n      calendar: instance,\n      // element identity, not keys: keys encode id+start only, so an end-only\n      // resize or a title/color/data edit would never invalidate a key-based\n      // memo. The index rebuilds occurrence objects precisely when events\n      // change, so identity is the correct (and cheapest) change signal.\n      isEqual: (a, b) => a === b || (a.length === b.length && a.every((occ, i) => occ === b[i])),\n    },\n  );\n}\n\nconst EMPTY_BUCKET: EventCalendarDayBucket = { allDay: [], timed: [] };\n\n/** Per-cell subscription: only cells whose segments changed re-render. */\nfunction useEventCalendarDay<TData = unknown>(\n  day: Date,\n): {\n  segments: EventCalendarDayBucket<TData>;\n  isToday: boolean;\n  isOutside: boolean;\n} {\n  const instance = useEventCalendar<TData>();\n  const { timeZone } = instance.settings;\n  const dayKey = getDayKey(day, timeZone);\n\n  const bucket = useEventCalendarSelector<TData, EventCalendarDayBucket<TData>>(\n    () =>\n      instance.internals.getIndex().byDay.get(dayKey) ??\n      (EMPTY_BUCKET as EventCalendarDayBucket<TData>),\n    {\n      calendar: instance,\n      // Element identity, not a content key: an occurrence key encodes\n      // id+start only, so a title/color/data edit produced an identical key\n      // and this cell kept serving its STALE segments. Buckets come straight\n      // out of the memoized index, which rebuilds precisely when events\n      // change, so identity is both correct and the cheapest signal.\n      isEqual: (a, b) =>\n        a === b ||\n        (a.allDay.length === b.allDay.length &&\n          a.timed.length === b.timed.length &&\n          a.allDay.every((segment, i) => segment === b.allDay[i]) &&\n          a.timed.every((segment, i) => segment === b.timed[i])),\n    },\n  );\n  const activeRange = useEventCalendarSelector<TData, EventCalendarDateRange>(\n    (state) => state.activeRange,\n    {\n      calendar: instance,\n      isEqual: (a, b) => getRangeKey(a) === getRangeKey(b),\n    },\n  );\n  useMidnightTick(timeZone);\n  const dayStart = zonedStartOfDay(day, timeZone);\n  return {\n    segments: bucket,\n    isToday: getDayKey(new Date(), timeZone) === dayKey,\n    isOutside: dayStart < activeRange.start || dayStart >= activeRange.end,\n  };\n}\n\nconst EMPTY_BARS: EventCalendarSegment[] = [];\n\n/**\n * Per-week-row subscription for the month view: the laned multi-day/all-day\n * bar segments (one per occurrence per row, colStart/colSpan/lane set) that\n * render as continuous cross-day bars. `laneCount` is the row's bar height.\n * Matched by CONTAINMENT - any day inside the row resolves it - so a\n * weekends-hidden month (first visible day Monday) still finds its row;\n * `rowStart` returns the row's TRUE start for colStart/colSpan day math.\n */\nfunction useEventCalendarWeek<TData = unknown>(\n  day: Date,\n): {\n  bars: EventCalendarSegment<TData>[];\n  laneCount: number;\n  rowStart: Date | null;\n} {\n  const instance = useEventCalendar<TData>();\n  const { timeZone } = instance.settings;\n  const dayStartMs = zonedStartOfDay(day, timeZone).getTime();\n\n  const row = useEventCalendarSelector<\n    TData,\n    { bars: EventCalendarSegment<TData>[]; rowStart: Date | null }\n  >(\n    () => {\n      const index = instance.internals.getIndex();\n      const match = index.weekRows.find((r) => {\n        const startMs = zonedStartOfDay(r.rowStart, timeZone).getTime();\n        // calendar-aware row end: a fixed 168h window would let the first\n        // day AFTER a spring-forward week (167h long) match the wrong row\n        const endMs = zonedStartOfDay(\n          addDays(toZoned(r.rowStart, timeZone), 7),\n          timeZone,\n        ).getTime();\n        return dayStartMs >= startMs && dayStartMs < endMs;\n      });\n      return {\n        bars: match?.bars ?? (EMPTY_BARS as EventCalendarSegment<TData>[]),\n        rowStart: match?.rowStart ?? null,\n      };\n    },\n    {\n      calendar: instance,\n      // Same reasoning as the day bucket above: `bars` is the array held by\n      // the memoized index, so element identity catches content edits that a\n      // key built from id+start could never see.\n      isEqual: (a, b) =>\n        (a.rowStart?.getTime() ?? 0) === (b.rowStart?.getTime() ?? 0) &&\n        (a.bars === b.bars ||\n          (a.bars.length === b.bars.length && a.bars.every((segment, i) => segment === b.bars[i]))),\n    },\n  );\n  const laneCount = row.bars.reduce((m, s) => Math.max(m, (s.lane ?? 0) + 1), 0);\n  return { bars: row.bars, laneCount, rowStart: row.rowStart };\n}\n\n/**\n * User view settings (weekends, week numbers, now line, off days, schedule\n * hint) + the effective values after falling back to the root view-config\n * props. Drives the nav submenu; fully controllable from outside via\n * `viewSettings`/`onViewSettingsChange` or api.setViewSettings.\n */\nfunction useEventCalendarViewSettings(): {\n  viewSettings: EventCalendarViewSettings;\n  setViewSettings: (patch: EventCalendarViewSettings) => void;\n  effective: Required<EventCalendarViewSettings>;\n} {\n  const instance = useEventCalendar();\n  const viewConfig = useEventCalendarViewConfig();\n  const viewSettings = useEventCalendarSelector((state) => state.viewSettings);\n  return {\n    viewSettings,\n    setViewSettings: instance.api.setViewSettings,\n    effective: {\n      weekends: viewSettings.weekends ?? true,\n      weekNumbers: viewSettings.weekNumbers ?? viewConfig.showWeekNumbers,\n      nowIndicator: viewSettings.nowIndicator ?? viewConfig.nowIndicator,\n      offDays:\n        viewSettings.offDays ?? (viewConfig.offDays !== undefined && viewConfig.offDays !== false),\n    },\n  };\n}\n\n/** Subscribes to settings changes only (version counter, not state). */\nfunction useEventCalendarSettingsVersion<TData>(instance: EventCalendarInstance<TData>): number {\n  return useSyncExternalStore(\n    instance.subscribe,\n    instance.internals.getSettingsVersion,\n    instance.internals.getSettingsVersion,\n  );\n}\n\n/** Resolved settings incl. merged i18n; re-renders only when settings change. */\nfunction useEventCalendarSettings<TData = unknown>(): EventCalendarSettings<TData> {\n  const instance = useEventCalendar<TData>();\n  useEventCalendarSettingsVersion(instance);\n  return instance.settings;\n}\n\nconst EventCalendarViewContext = createContext<{ view: CalendarView } | null>(null);\n\n/**\n * Per-element class hooks, cn()-merged AFTER the built-in classes (so tailwind\n * variants and ! overrides win). Metric CSS variables can ride on any parent\n * key, e.g. classNames.timeGrid: \"[--ec-gutter-width:4.5rem]\".\n */\ninterface EventCalendarClassNames {\n  nav?: string;\n  toolbar?: string;\n  content?: string;\n  monthView?: string;\n  monthCell?: string;\n  timeGrid?: string;\n  timeGutter?: string;\n  dayColumn?: string;\n  allDaySection?: string;\n  agendaView?: string;\n  event?: string;\n  /** The styled hover tooltip popup (viewConfig.eventTooltip). */\n  eventTooltip?: string;\n  moreIndicator?: string;\n  /** The \"+N more\" popover panel. Control the on-demand scroll cap through\n   *  the CSS variable, e.g. \"[--ec-more-max-height:20rem]\". */\n  morePopover?: string;\n  /** \"+N more\" popover day header row. */\n  morePopoverHeader?: string;\n  // nav family (reachable without recomposing the default nav)\n  navButton?: string;\n  title?: string;\n  navTooltip?: string;\n  viewSwitcherContent?: string;\n  viewSwitcherLabel?: string;\n  viewShortcut?: string;\n  datePickerContent?: string;\n  // month view\n  monthHeader?: string;\n  monthDayHeader?: string;\n  monthBody?: string;\n  monthRow?: string;\n  weekNumber?: string;\n  monthBarOverlay?: string;\n  monthBar?: string;\n  monthCellContent?: string;\n  monthCellFooter?: string;\n  monthDayNumber?: string;\n  dayAddButton?: string;\n  // time grid / resource\n  timeGridHeader?: string;\n  timeGutterLabel?: string;\n  allDayLabel?: string;\n  allDayCell?: string;\n  timedChip?: string;\n  resourceHeader?: string;\n  // interaction surfaces (shared by every view)\n  dragGhost?: string;\n  dragCarry?: string;\n  dragCarryInvalid?: string;\n  dropHint?: string;\n  dropIndicator?: string;\n  slotDraft?: string;\n  resizeHandle?: string;\n  resizeGrip?: string;\n  // agenda\n  noEvents?: string;\n  agendaDay?: string;\n  agendaDayHeader?: string;\n  agendaDayGutter?: string;\n  agendaDate?: string;\n  agendaDayToggle?: string;\n  agendaDayContent?: string;\n  agendaItem?: string;\n  agendaItemSurface?: string;\n  agendaItemToggle?: string;\n  agendaDaySummary?: string;\n  agendaSummaryDot?: string;\n}\n\ninterface EventCalendarRenderEventProps<TData = unknown> {\n  occurrence: EventCalendarOccurrence<TData>;\n  segment: EventCalendarSegment<TData>;\n  view: CalendarView;\n  isDragging: boolean;\n  isSelected: boolean;\n}\n\n/**\n * View-layer configuration: display props and render overrides. These live on\n * <EventCalendar> (and per-view components), never in the headless options.\n */\ninterface EventCalendarViewConfig<TData = unknown> {\n  scrollToHour: number;\n  nowIndicator: boolean;\n  /**\n   * Grid interval in minutes for the time-based views (day, week, N-days,\n   * time grid): gutter slots and gridlines follow it. Also accepted as a\n   * prop on each view component.\n   */\n  interval: number;\n  maxEventsPerCell: number | \"auto\";\n  showWeekNumbers: boolean;\n  /**\n   * Single-key view shortcuts (`labels.viewShortcuts`, and each day-count\n   * preset's number), also shown as hints in the view switcher. Ignored with\n   * a modifier held, while typing in a field, or inside an overlay.\n   */\n  enableShortcuts: boolean;\n  /**\n   * \"focus-within\" (default): keys work only while focus is inside the\n   * calendar. \"global\": keys work anywhere on the page.\n   */\n  shortcutsScope: \"focus-within\" | \"global\";\n  /**\n   * \"contained\" (default): the calendar fills its container and views scroll\n   * internally. \"page\": content flows with the document, the page scrolls,\n   * and day headers stick below `--ec-sticky-offset` (default 0px).\n   */\n  scrollMode: \"contained\" | \"page\";\n  /** Stick the default nav to the top while the page scrolls. */\n  stickyNav: boolean;\n  /**\n   * Custom per-day indication (light background classes work in both themes,\n   * e.g. \"bg-amber-500/10\"). Applied to month cells, time-grid day columns,\n   * and all-day cells; content stays readable on top of it.\n   */\n  dayClassName?: (day: Date) => string | undefined;\n  /**\n   * Extra classes for the CURRENT day, appended after the built-in highlight\n   * (primary-tinted background + accent top border) on month cells, time-grid\n   * day columns, and day headers.\n   */\n  todayClassName?: string;\n  /**\n   * Show a \"+\" add button on month cells (next to the day number) and on\n   * time-grid day headers. It stays visible (highlighted on hover/focus), is labelled\n   * with the date (`labels.addEventOn`) and fires the same onSlotClick as\n   * clicking the day - the keyboard way to start creating an event, since\n   * drag-to-create needs a pointer. Use renderMonthCell / renderDayHeader for\n   * fully custom markup.\n   */\n  showDayAddButton: boolean;\n  /**\n   * Scroll implementation for every internally scrolling surface (time grid,\n   * agenda, time-grid resources, month \"+N more\" popover):\n   * \"custom\" (default, shadcn ScrollArea) or \"native\" (browser scrollbars\n   * via overflow auto).\n   */\n  scrollbars: \"custom\" | \"native\";\n  /** Nav button variant; all nav buttons follow it. Default \"ghost\". */\n  navButtonVariant: \"ghost\" | \"outline\" | \"secondary\" | \"default\";\n  /** Nav button size; icon buttons use the icon twin. Default \"sm\". */\n  navButtonSize: \"sm\" | \"default\";\n  /**\n   * Off-day (non-working day) marking. true = weekends with a muted\n   * background; a config object customizes weekdays, explicit dates, a\n   * predicate, and the marker class. Marked cells carry data-off.\n   */\n  offDays?: boolean | EventCalendarOffDaysConfig;\n  classNames?: EventCalendarClassNames;\n  components?: Partial<Record<CalendarView, ComponentType>>;\n  renderEvent?: (props: EventCalendarRenderEventProps<TData>) => ReactNode;\n  renderAgendaEvent?: (props: EventCalendarRenderEventProps<TData>) => ReactNode;\n  /**\n   * Content for the styled hover tooltip (viewConfig.eventTooltip). Return a\n   * falsy value (null / undefined, or the `false` a `cond && <node>` yields)\n   * to fall back to the default label (title + time); `label` itself is\n   * undefined when a consumer i18n.formatEventLabel opts out.\n   */\n  renderEventTooltip?: (props: {\n    occurrence: EventCalendarOccurrence<TData>;\n    segment: EventCalendarSegment<TData>;\n    view: CalendarView;\n    label: string | undefined;\n  }) => ReactNode;\n  renderDragPreview?: (props: { drag: EventCalendarDragState<TData> }) => ReactNode;\n  renderMonthCell?: (props: {\n    day: Date;\n    segments: EventCalendarDayBucket<TData>;\n    isToday: boolean;\n    isOutside: boolean;\n    overflowCount: number;\n    defaultContent: ReactNode;\n  }) => ReactNode;\n  /**\n   * Time-grid business-logic layer, rendered pointer-events-none BEHIND event\n   * segments in each day column. Position overlays with\n   * top/height: calc(var(--ec-hour-height) * minutes / 60).\n   */\n  renderDayColumnBackground?: (props: {\n    day: Date;\n    boundsStartMin: number;\n    boundsEndMin: number;\n    totalMinutes: number;\n  }) => ReactNode;\n  renderDayHeader?: (props: { day: Date; view: CalendarView; isToday: boolean }) => ReactNode;\n  renderTimeGutterSlot?: (props: { time: Date; hour: number; minute: number }) => ReactNode;\n  renderAllDaySection?: (props: {\n    days: Date[];\n    segments: EventCalendarSegment<TData>[];\n  }) => ReactNode;\n  renderMoreIndicator?: (props: {\n    day: Date;\n    count: number;\n    segments: EventCalendarSegment<TData>[];\n  }) => ReactNode;\n  /**\n   * Replaces the ENTIRE body of the built-in \"+N more\" popover (header +\n   * chip list) while keeping its trigger and positioning; `close` dismisses\n   * it. For a fully custom surface, return false from onMoreClick instead\n   * and open your own UI.\n   */\n  renderMoreContent?: (props: {\n    day: Date;\n    segments: EventCalendarSegment<TData>[];\n    close: () => void;\n  }) => ReactNode;\n  /**\n   * Agenda-only expandable details. When it returns a node for an occurrence,\n   * the agenda row gains an expand/collapse toggle revealing the details\n   * below the chip (the calendar itself never knows the consumer's fields).\n   */\n  renderAgendaEventDetails?: (occurrence: EventCalendarOccurrence<TData>) => ReactNode;\n  renderNowIndicator?: (props: { time: Date }) => ReactNode;\n  renderNoEvents?: () => ReactNode;\n  /** Resource column header cell content; default is resource.title. */\n  renderResourceHeader?: (props: { resource: EventCalendarResource }) => ReactNode;\n  /** Agenda date gutter (day badge + weekday + collapse toggle). */\n  renderAgendaDayHeader?: (props: {\n    day: Date;\n    collapsed: boolean;\n    count: number;\n    toggle: () => void;\n    defaultContent: ReactNode;\n  }) => ReactNode;\n  /** Collapsed agenda day summary row content. */\n  renderAgendaDaySummary?: (props: {\n    day: Date;\n    occurrences: EventCalendarOccurrence<TData>[];\n    count: number;\n    expand: () => void;\n    defaultContent: ReactNode;\n  }) => ReactNode;\n  /**\n   * N-day presets offered by the view switcher when the \"days\" view is\n   * enabled. @default [5]\n   */\n  dayCountPresets: number[];\n  /**\n   * Nav tooltips: false disables them all; an object tunes placement and\n   * timings. @default { side: \"bottom\", delay: 600, closeDelay: 0, timeout: 300 }\n   */\n  navTooltips?:\n    | false\n    | {\n        side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n        delay?: number;\n        closeDelay?: number;\n        timeout?: number;\n      };\n  /**\n   * Styled tooltip on event hover / keyboard focus. `false` (default) keeps\n   * only the native title attribute; `true` shows the standard Tooltip with\n   * the event label; an object also tunes the side and open delay. Content is\n   * overridable with renderEventTooltip. @default false\n   */\n  eventTooltip?:\n    | boolean\n    | {\n        side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n        delay?: number;\n      };\n  /**\n   * Timed events shorter than this render the compact single-row chip layout.\n   * @default 45\n   */\n  compactEventMinutes: number;\n  /** \"+N more\" popover alignment against its trigger. @default \"start\" */\n  morePopoverAlign: \"start\" | \"center\" | \"end\";\n  /** Now-indicator refresh cadence in milliseconds. @default 30000 */\n  nowIndicatorInterval: number;\n  /** Max color dots in a collapsed agenda day summary. @default 6 */\n  agendaSummaryMaxDots: number;\n}\n\nconst DEFAULT_VIEW_CONFIG: EventCalendarViewConfig = {\n  scrollToHour: 7,\n  nowIndicator: true,\n  interval: 60,\n  maxEventsPerCell: \"auto\",\n  showWeekNumbers: false,\n  enableShortcuts: true,\n  shortcutsScope: \"focus-within\",\n  scrollMode: \"contained\",\n  stickyNav: false,\n  showDayAddButton: false,\n  scrollbars: \"custom\",\n  navButtonVariant: \"ghost\",\n  navButtonSize: \"sm\",\n  dayCountPresets: [5],\n  eventTooltip: false,\n  compactEventMinutes: 45,\n  morePopoverAlign: \"start\",\n  nowIndicatorInterval: 30_000,\n  agendaSummaryMaxDots: 6,\n};\n\nconst EventCalendarViewConfigContext =\n  createContext<\n    // biome-ignore lint/suspicious/noExplicitAny: shared across every TData\n    EventCalendarViewConfig<any>\n  >(DEFAULT_VIEW_CONFIG);\n\n/** Root-level display props + render overrides, for view components. */\nfunction useEventCalendarViewConfig<TData = unknown>(): EventCalendarViewConfig<TData> {\n  return useContext(EventCalendarViewConfigContext);\n}\n\nconst VIEW_CONFIG_KEYS: Array<keyof EventCalendarViewConfig> = [\n  \"scrollToHour\",\n  \"nowIndicator\",\n  \"interval\",\n  \"maxEventsPerCell\",\n  \"showWeekNumbers\",\n  \"enableShortcuts\",\n  \"shortcutsScope\",\n  \"scrollMode\",\n  \"stickyNav\",\n  \"dayClassName\",\n  \"todayClassName\",\n  \"showDayAddButton\",\n  \"scrollbars\",\n  \"navButtonVariant\",\n  \"navButtonSize\",\n  \"offDays\",\n  \"classNames\",\n  \"components\",\n  \"renderEvent\",\n  \"renderAgendaEvent\",\n  \"renderEventTooltip\",\n  \"renderDragPreview\",\n  \"renderMonthCell\",\n  \"renderDayColumnBackground\",\n  \"renderDayHeader\",\n  \"renderTimeGutterSlot\",\n  \"renderAllDaySection\",\n  \"renderMoreIndicator\",\n  \"renderMoreContent\",\n  \"renderNowIndicator\",\n  \"renderNoEvents\",\n  \"renderAgendaEventDetails\",\n  \"renderResourceHeader\",\n  \"renderAgendaDayHeader\",\n  \"renderAgendaDaySummary\",\n  \"dayCountPresets\",\n  \"navTooltips\",\n  \"eventTooltip\",\n  \"compactEventMinutes\",\n  \"morePopoverAlign\",\n  \"nowIndicatorInterval\",\n  \"agendaSummaryMaxDots\",\n];\n\n/** The rendering view of the nearest view component (\"month\", \"week\", ...). */\nfunction useEventCalendarViewContext(): { view: CalendarView } {\n  const ctx = useContext(EventCalendarViewContext);\n  if (!ctx) {\n    throw new Error(\"useEventCalendarViewContext must be used inside a calendar view\");\n  }\n  return ctx;\n}\n\ninterface EventCalendarProps<TData = unknown>\n  extends UseEventCalendarStateOptions<TData>,\n    Partial<EventCalendarViewConfig<TData>>,\n    Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> {\n  /** Adopt a hoisted useEventCalendarState instance; option props are then ignored. */\n  calendar?: EventCalendarInstance<TData>;\n  /** Imperative escape hatch usable from outside the tree. */\n  apiRef?: RefObject<EventCalendarApi<TData> | null>;\n  children?: ReactNode;\n}\n\nconst OPTION_KEYS: Array<keyof UseEventCalendarStateOptions> = [\n  \"events\",\n  \"defaultEvents\",\n  \"view\",\n  \"defaultView\",\n  \"date\",\n  \"defaultDate\",\n  \"dayCount\",\n  \"defaultDayCount\",\n  \"selection\",\n  \"defaultSelection\",\n  \"interactions\",\n  \"defaultInteractions\",\n  \"viewSettings\",\n  \"defaultViewSettings\",\n  \"loading\",\n  \"views\",\n  \"timeZone\",\n  \"locale\",\n  \"weekStartsOn\",\n  \"dayStartHour\",\n  \"dayEndHour\",\n  \"slotDuration\",\n  \"snapDuration\",\n  \"agendaDayCount\",\n  \"fixedWeeks\",\n  \"showOutsideDays\",\n  \"i18n\",\n  \"resources\",\n  \"getEventPriority\",\n  \"eventOrder\",\n  \"getOccurrences\",\n  \"weekendDays\",\n  \"activation\",\n  \"onEventClick\",\n  \"onEventDoubleClick\",\n  \"onEventUpdate\",\n  \"canDropEvent\",\n  \"onDragBlocked\",\n  \"onSlotClick\",\n  \"onSelectSlot\",\n  \"canSelectSlot\",\n  \"onRangeChange\",\n  \"onViewChange\",\n  \"onDateChange\",\n  \"onDayCountChange\",\n  \"onSelectionChange\",\n  \"onInteractionsChange\",\n  \"onViewSettingsChange\",\n  \"onEventsChange\",\n  \"onMoreClick\",\n];\n\nfunction splitOptions<TData>(props: Record<string, unknown>): {\n  options: UseEventCalendarStateOptions<TData>;\n  viewConfig: EventCalendarViewConfig<TData>;\n  rest: Record<string, unknown>;\n} {\n  const options: Record<string, unknown> = {};\n  const viewConfig: Record<string, unknown> = { ...DEFAULT_VIEW_CONFIG };\n  const rest: Record<string, unknown> = {};\n  for (const [key, value] of Object.entries(props)) {\n    if ((OPTION_KEYS as string[]).includes(key)) options[key] = value;\n    else if ((VIEW_CONFIG_KEYS as string[]).includes(key)) {\n      if (value !== undefined) viewConfig[key] = value;\n    } else rest[key] = value;\n  }\n  return {\n    options: options as UseEventCalendarStateOptions<TData>,\n    viewConfig: viewConfig as unknown as EventCalendarViewConfig<TData>,\n    rest,\n  };\n}\n\nconst SHORTCUT_IGNORED_TARGETS =\n  \"input, textarea, select, [contenteditable]:not([contenteditable=false]), [role=dialog], [role=alertdialog], [role=menu], [role=listbox]\";\n\n/**\n * Switches views on single-key shortcuts. Listens natively, in the capture\n * phase, on the root (\"focus-within\") or on `window` (\"global\"): react-aria\n * stops keydown propagation in its components, so a React `onKeyDown` on the\n * root would miss keys pressed on a focused button. A native listener on the\n * root also never sees keys from portaled surfaces (popovers, menus).\n */\nfunction useViewShortcuts<TData>(\n  instance: EventCalendarInstance<TData>,\n  viewConfig: Pick<\n    EventCalendarViewConfig,\n    \"enableShortcuts\" | \"shortcutsScope\" | \"dayCountPresets\"\n  >,\n  rootRef: RefObject<HTMLElement | null>,\n) {\n  const presetsRef = useRef(viewConfig.dayCountPresets);\n  presetsRef.current = viewConfig.dayCountPresets;\n  const { enableShortcuts, shortcutsScope } = viewConfig;\n\n  useEffect(() => {\n    const target = shortcutsScope === \"global\" ? window : rootRef.current;\n    if (!enableShortcuts || !target) return;\n\n    const onKeyDown = (event: Event) => {\n      if (!(event instanceof KeyboardEvent) || event.repeat) return;\n      if (event.ctrlKey || event.metaKey || event.altKey) return;\n      if (!(event.target instanceof Element)) return;\n      if (event.target.closest(SHORTCUT_IGNORED_TARGETS)) return;\n\n      const key = event.key.toLowerCase();\n      const { views, i18n } = instance.settings;\n      for (const view of views) {\n        if (view === \"days\") {\n          const count = presetsRef.current.find((n) => String(n) === key);\n          if (count === undefined) continue;\n          event.preventDefault();\n          instance.api.setView(\"days\", { dayCount: count });\n          return;\n        }\n        if (i18n.labels.viewShortcuts[view]?.toLowerCase() === key) {\n          event.preventDefault();\n          instance.api.setView(view);\n          return;\n        }\n      }\n    };\n\n    target.addEventListener(\"keydown\", onKeyDown, true);\n    return () => target.removeEventListener(\"keydown\", onKeyDown, true);\n  }, [instance, enableShortcuts, shortcutsScope, rootRef]);\n}\n\n/**\n * Root provider + container. Composition contract:\n * <EventCalendar><EventCalendarNav/><EventCalendarToolbar/><EventCalendarContent/></EventCalendar>\n */\nfunction EventCalendar<TData = unknown>({\n  calendar,\n  apiRef,\n  className,\n  children,\n  ...props\n}: EventCalendarProps<TData>) {\n  const { options, viewConfig, rest } = splitOptions<TData>(props as Record<string, unknown>);\n\n  if (calendar && Object.keys(options).length > 0) {\n    warnOnce(\n      \"calendar-and-options\",\n      \"both `calendar` and option props were passed; option props are ignored when adopting an instance.\",\n    );\n  }\n\n  const own = useEventCalendarState<TData>(calendar ? {} : options);\n  const instance = calendar ?? own;\n\n  useEffect(() => {\n    if (apiRef) apiRef.current = instance.api;\n  }, [apiRef, instance]);\n\n  // Register the root element so the drag engine can find day cells even when a\n  // gesture starts from a portaled surface (the \"+N more\" popover).\n  const rootRef = useRef<HTMLElement | null>(null);\n  const registerRoot = useCallback(\n    (el: HTMLElement | null) => {\n      rootRef.current = el;\n      instance.internals.setRootEl(el);\n    },\n    [instance],\n  );\n  useViewShortcuts(instance, viewConfig, rootRef);\n\n  const defaultProps = {\n    \"data-slot\": \"event-calendar\",\n    ref: registerRoot,\n    // text-xs is the calendar-wide default type size; because it sits before\n    // `className`, a consumer can override the whole scale with e.g.\n    // <EventCalendar className=\"text-sm\"> and every inheriting element follows.\n    // Inner elements omit their own text-size so they cascade from here (the\n    // few portaled surfaces - \"+N more\" popover, drag carry clone - pin the\n    // size explicitly since DOM inheritance does not cross a portal).\n    className: cn(\"flex min-h-0 min-w-0 flex-col text-xs\", className),\n    children: (\n      <>\n        {children}\n        <div data-slot=\"event-calendar-announcer\" aria-live=\"polite\" className=\"sr-only\" />\n      </>\n    ),\n  };\n\n  return (\n    <EventCalendarContext.Provider value={instance}>\n      <EventCalendarViewConfigContext.Provider value={viewConfig}>\n        <div {...mergeProps<React.ComponentProps<\"div\">>(defaultProps, rest)} />\n      </EventCalendarViewConfigContext.Provider>\n    </EventCalendarContext.Provider>\n  );\n}\n\nexport type {\n  EventCalendarActivationConfig,\n  EventCalendarApi,\n  EventCalendarCallbacks,\n  EventCalendarClassNames,\n  EventCalendarInstance,\n  EventCalendarInternals,\n  EventCalendarProps,\n  EventCalendarRenderEventProps,\n  EventCalendarSettings,\n  EventCalendarViewConfig,\n  UseEventCalendarStateOptions,\n};\nexport {\n  ALL_VIEWS,\n  BASE_VIEWS,\n  DEFAULT_VIEW_CONFIG,\n  EventCalendar,\n  EventCalendarContext,\n  EventCalendarViewConfigContext,\n  EventCalendarViewContext,\n  useEventCalendar,\n  useEventCalendarDay,\n  useEventCalendarInteractions,\n  useEventCalendarNavigation,\n  useEventCalendarOccurrences,\n  useEventCalendarSelection,\n  useEventCalendarSelector,\n  useEventCalendarSettings,\n  useEventCalendarSettingsVersion,\n  useEventCalendarState,\n  useEventCalendarView,\n  useEventCalendarViewConfig,\n  useEventCalendarViewContext,\n  useEventCalendarViewSettings,\n  useEventCalendarWeek,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ui/event-calendar/event-calendar.tsx"
    }
  ],
  "meta": {
    "status": "stable",
    "source": "third-party",
    "origin": "reui.io (https://reui.io/docs/components/base/event-calendar, MIT), via EMITTE; overlays rebuilt on the registry's react-aria components",
    "tags": [
      "block",
      "calendar",
      "scheduler",
      "events",
      "recurrence"
    ]
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}