reui

Event Calendar

Event Calendar — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).

Загрузка превью…

src/reui/event-calendar/EventCalendar.vue

<script setup lang="ts" generic="TData = unknown">
/**
 * Порт корневого ReUI `<EventCalendar>` (event-calendar.tsx, MIT) —
 * провайдер движка + контейнер + announcer.
 *
 * ponytail: принимает `EventCalendarOptions` (состояние/колбэки) плюс один
 * плоский `viewConfig`-проп (частичный `EventCalendarViewConfig`, см.
 * `context.ts`) — а не полный список отдельных плоских пропов
 * (`maxEventsPerCell`, `renderEvent` и т.п. по одному), как в оригинале
 * (`splitOptions`/`OPTION_KEYS`/`VIEW_CONFIG_KEYS`). Добавлено, когда
 * `packages/blocks/src/event-calendar/` понадобился root-level оверрайд
 * `renderEvent`/`renderEventTooltip`/`eventTooltip`/`maxEventsPerCell`
 * (composite-блоки `c-event-calendar-1/4/5`, которые в оригинале передают
 * эти пропы прямо на `<EventCalendar>`, а не на отдельный view-компонент,
 * как единственный до того кейс `ui-event-calendar`). Не реализованы
 * `calendar`/`apiRef`-пропы (adopt вынесенного инстанса) и `asChild` — оба
 * используются только вокруг составных сценариев (внешнее состояние,
 * полиморфный корень), которых пока нет ни у одного кейса/блока.
 *
 * Боевая реализация `EventCalendarGestures` (`dnd.ts`) провайдится здесь —
 * единственное место, которое знает и про движок, и про pointer-engine;
 * потребители (`EventCalendarEvent.vue` и виды) не меняются вообще, они уже
 * читали заглушку через тот же `EventCalendarGesturesContextKey`.
 */
import { provide, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import {
  createEventCalendar,
  DEFAULT_EVENT_CALENDAR_VIEW_CONFIG,
  EventCalendarContextKey,
  EventCalendarGesturesContextKey,
  EventCalendarViewConfigContextKey,
  type EventCalendarOptions,
  type EventCalendarViewConfig,
} from "./context"
import { createEventCalendarGestures } from "./dnd"

// withDefaults(..., { fixedWeeks: undefined, ... }) is load-bearing, not
// decorative: Vue casts an ABSENT boolean-typed prop to `false` (not
// `undefined`) unless it has an explicit `default` key — silently turning
// `fixedWeeks ?? true` in resolveSettings() into `false ?? true` => `false`
// (5 grid rows instead of 6). An explicit `default: undefined` makes
// `hasDefault` true and suppresses that cast. Same trap for every other
// boolean-typed field of EventCalendarOptions (loading, showOutsideDays).
const props = withDefaults(
  defineProps<
    EventCalendarOptions<TData> & {
      class?: HTMLAttributes["class"]
      viewConfig?: Partial<EventCalendarViewConfig<TData>>
    }
  >(),
  { fixedWeeks: undefined, showOutsideDays: undefined, loading: undefined }
)

const instance = createEventCalendar<TData>(props)
provide(EventCalendarContextKey, instance)
provide(EventCalendarGesturesContextKey, createEventCalendarGestures<TData>(instance))
// Plain merged object, not a `computed()`: consumers (`useEventCalendarViewConfig()`
// callers) read fields straight off the injected value (`viewConfig.classNames`,
// `viewConfig.maxEventsPerCell`, ...) with no `.value` — same v1 uncontrolled
// contract as the rest of `EventCalendarOptions` (read once at creation, see
// file header). Providing a `ComputedRef` here instead would silently break
// every existing reader the same way §33 (gantt) documents.
provide(
  EventCalendarViewConfigContextKey,
  props.viewConfig
    ? { ...DEFAULT_EVENT_CALENDAR_VIEW_CONFIG, ...props.viewConfig }
    : DEFAULT_EVENT_CALENDAR_VIEW_CONFIG
)

defineExpose({ api: instance.api, state: instance.state })
</script>

<template>
  <div data-slot="event-calendar" :class="cn('flex min-h-0 min-w-0 flex-col text-xs', props.class)">
    <slot />
    <div data-slot="event-calendar-announcer" aria-live="polite" class="sr-only" />
  </div>
</template>

src/reui/event-calendar/EventCalendarAgendaView.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarAgendaView (event-calendar-agenda-view.tsx, MIT) —
 * хронологический список по дням: заголовок дня + плоский список событий.
 *
 * ponytail: `scrollbars: "native"` ветка не перенесена — всегда `ScrollArea`
 * (дефолт оригинала), как и в EventCalendarTimeGrid.vue; `asChild` тоже
 * пропущен. `renderNoEvents`/`renderAgendaEventDetails` (раскрывающиеся
 * детали строки) не перенесены — ни один текущий кейс не показывает пустой
 * агенду и не использует details-колбэк.
 * `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (путь
 * lucide "calendar" v0.545.0) внутри уже портированного `IconStack`.
 */
import { computed, provide } from "vue"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import { IconStack } from "@/components/reui/icon-stack"
import {
  EventCalendarViewContextKey,
  useEventCalendar,
  useEventCalendarSettings,
  useEventCalendarViewConfig,
} from "./context"
import { getDayKey, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { EventCalendarSegment } from "./types"

provide(EventCalendarViewContextKey, { view: "agenda" })

const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()

const visibleRange = computed(() => instance.state.value.visibleRange)

const days = computed(() => {
  const result: Date[] = []
  let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
  while (cursor < visibleRange.value.end) {
    result.push(cursor)
    cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
  }
  return result
})

interface DayGroup {
  day: Date
  bucket: EventCalendarDayBucket | undefined
  items: EventCalendarSegment[]
}

const groups = computed<DayGroup[]>(() => {
  const index = instance.api.getIndex()
  return days.value
    .map((day) => ({ day, bucket: index.byDay.get(getDayKey(day, settings.timeZone)) }))
    .filter((g) => (g.bucket?.allDay.length ?? 0) + (g.bucket?.timed.length ?? 0) > 0)
    .map((g) => ({ ...g, items: [...(g.bucket?.allDay ?? []), ...(g.bucket?.timed ?? [])] }))
})

function isToday(day: Date): boolean {
  return getDayKey(day, settings.timeZone) === getDayKey(new Date(), settings.timeZone)
}
</script>

<template>
  <div
    data-slot="event-calendar-agenda-view"
    data-view="agenda"
    role="group"
    :aria-label="settings.i18n.functions.formatDayRange(visibleRange, { locale: settings.locale })"
    :class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.agendaView)"
  >
    <ScrollArea class="h-full">
      <div v-if="groups.length === 0" data-slot="event-calendar-no-events" :class="cn('flex min-h-72 flex-col items-center justify-center gap-4 py-16', viewConfig.classNames?.noEvents)">
        <IconStack>
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5" aria-hidden="true"><path d="M8 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></svg>
        </IconStack>
        <span class="text-muted-foreground text-sm">{{ settings.i18n.labels.noEvents }}</span>
      </div>
      <div v-else class="flex flex-col [&>*:last-child>*:last-child]:border-b-0">
        <div
          v-for="group in groups"
          :key="group.day.getTime()"
          data-slot="event-calendar-agenda-day"
          :data-today="isToday(group.day) || undefined"
          role="group"
          :aria-label="`${format(toZoned(group.day, settings.timeZone), 'EEEE', { locale: settings.locale })}, ${format(toZoned(group.day, settings.timeZone), 'MMMM d, yyyy', { locale: settings.locale })}, ${settings.i18n.labels.events(group.items.length)}`"
        >
          <div
            data-slot="event-calendar-agenda-day-header"
            role="heading"
            aria-level="3"
            :class="cn('bg-muted/60 sticky top-0 z-10 flex items-baseline justify-between gap-4 border-b px-4 py-2 me-2.5', viewConfig.classNames?.agendaDayHeader)"
          >
            <span :class="cn('text-foreground font-semibold', isToday(group.day) && 'text-primary')">
              {{ format(toZoned(group.day, settings.timeZone), "EEEE", { locale: settings.locale }) }}
            </span>
            <span class="text-muted-foreground font-medium tabular-nums">
              {{ format(toZoned(group.day, settings.timeZone), "MMMM d, yyyy", { locale: settings.locale }) }}
            </span>
          </div>
          <EventCalendarEvent
            v-for="segment in group.items"
            :key="segment.occurrence.key"
            :segment="segment"
            :class="cn('hover:bg-accent/40 gap-3 rounded-none border-b px-4 py-2.5 transition-colors', viewConfig.classNames?.agendaItem)"
          />
        </div>
      </div>
    </ScrollArea>
  </div>
</template>

src/reui/event-calendar/EventCalendarContent.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarContent (event-calendar-content.tsx, MIT) —
 * коммутатор активного представления.
 *
 * ponytail: `DEFAULT_VIEW_COMPONENTS`/`components`-проп теперь содержат все
 * шесть `CalendarView` — весь визуальный слой представлений портирован
 * (см. docs/PORTING.md). Остаётся только `event-calendar-dnd.tsx`.
 */
import { computed, type Component, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useEventCalendar, useEventCalendarViewConfig } from "./context"
import EventCalendarMonthView from "./EventCalendarMonthView.vue"
import EventCalendarWeekView from "./EventCalendarWeekView.vue"
import EventCalendarDayView from "./EventCalendarDayView.vue"
import EventCalendarDaysView from "./EventCalendarDaysView.vue"
import EventCalendarAgendaView from "./EventCalendarAgendaView.vue"
import EventCalendarResourceView from "./EventCalendarResourceView.vue"
import type { CalendarView } from "./types"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  components?: Partial<Record<CalendarView, Component>>
}>()

const instance = useEventCalendar()
const viewConfig = useEventCalendarViewConfig()

const defaultViewComponents: Partial<Record<CalendarView, Component>> = {
  month: EventCalendarMonthView,
  week: EventCalendarWeekView,
  day: EventCalendarDayView,
  days: EventCalendarDaysView,
  agenda: EventCalendarAgendaView,
  resource: EventCalendarResourceView,
}

const view = computed(() => instance.state.value.view)
const loading = computed(() => instance.state.value.loading)
const activeView = computed(
  () =>
    props.components?.[view.value] ??
    viewConfig.components?.[view.value] ??
    defaultViewComponents[view.value]
)
</script>

<template>
  <div
    data-slot="event-calendar-content"
    :data-view="view"
    :data-loading="loading || undefined"
    :class="
      cn(
        'relative flex min-h-0 min-w-0 flex-1 flex-col',
        'data-loading:pointer-events-none data-loading:opacity-60',
        viewConfig.classNames?.content,
        props.class
      )
    "
  >
    <slot>
      <component :is="activeView" v-if="activeView" />
    </slot>
  </div>
</template>

src/reui/event-calendar/EventCalendarDayView.vue

<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="day"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"

// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    dayStartHour?: number
    dayEndHour?: number
    showAllDay?: boolean
    interval?: number
  }>(),
  { showAllDay: undefined }
)
</script>

<template>
  <EventCalendarTimeGrid
    view="day"
    :class="props.class"
    :day-start-hour="props.dayStartHour"
    :day-end-hour="props.dayEndHour"
    :show-all-day="props.showAllDay"
    :interval="props.interval"
  />
</template>

src/reui/event-calendar/EventCalendarDaysView.vue

<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="days"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"

// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    dayStartHour?: number
    dayEndHour?: number
    showAllDay?: boolean
    interval?: number
  }>(),
  { showAllDay: undefined }
)
</script>

<template>
  <EventCalendarTimeGrid
    view="days"
    :class="props.class"
    :day-start-hour="props.dayStartHour"
    :day-end-hour="props.dayEndHour"
    :show-all-day="props.showAllDay"
    :interval="props.interval"
  />
</template>

src/reui/event-calendar/EventCalendarEvent.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarEvent (event-calendar-event.tsx, MIT) — переиспользуемый
 * чип/бар/блок события: позиционирование остаётся за вызывающим представлением
 * (month-view/time-grid/agenda-view), этот компонент — сам интерактивный
 * элемент: выбор, клики, атрибуты доступности, --ec-event-color.
 *
 * ponytail: перенесены выбор/клики/дефолтный контент/ручки ресайза (сама
 * разметка `data-slot="event-calendar-resize-handle"`); ЧТО ПРОПУЩЕНО и
 * почему:
 *  - `TooltipProvider`/`Tooltip` (`viewConfig.eventTooltip`) — дефолт `false`,
 *    не нужен для первого рендера; добавить как обычный reui/tooltip враппер,
 *    когда появится кейс с `eventTooltip: true`.
 *  - `useMemo` для `customContent` — в оригинале это защита от лишних
 *    ре-рендеров React при перетаскивании; Vue `computed()` даёт то же самое
 *    бесплатно (см. context.ts header, тот же приём).
 * `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (путь
 * lucide "repeat" v0.545.0), как и во всех остальных портах.
 */
import { computed, type HTMLAttributes, type StyleValue } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import {
  useEventCalendar,
  useEventCalendarGestures,
  useEventCalendarSettings,
  useEventCalendarViewConfig,
  useEventCalendarViewContext,
} from "./context"
import {
  spansMultipleDays,
  toZoned,
  zonedStartOfDay,
} from "./lib"
import type { EventCalendarSegment } from "./types"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    segment: EventCalendarSegment
    /** Static drag clone: chip rendered as-is but inert. */
    preview?: boolean
    asChild?: boolean
    style?: StyleValue
  }>(),
  { preview: false, asChild: false }
)

defineSlots<{ default?(): unknown }>()

const { forwardRef } = useForwardExpose()
const instance = useEventCalendar()
const viewConfig = useEventCalendarViewConfig()
const settings = useEventCalendarSettings()
const { view } = useEventCalendarViewContext()
const gestures = useEventCalendarGestures()

const occurrence = computed(() => props.segment.occurrence)
const event = computed(() => occurrence.value.event)

const isSelectedRaw = computed(() =>
  instance.state.value.selection.eventKeys.includes(occurrence.value.key)
)
const isDraggingRaw = computed(
  () => instance.state.value.drag?.occurrence.key === occurrence.value.key
)
const resizeOn = computed(() => instance.state.value.interactions.resize)
// A preview clone must never inherit the source's selected/dragging state.
const isSelected = computed(() => (props.preview ? false : isSelectedRaw.value))
const isDragging = computed(() => (props.preview ? false : isDraggingRaw.value))

const isBar = computed(
  () => occurrence.value.allDay || spansMultipleDays(occurrence.value, settings.timeZone)
)
const inTimeGrid = computed(
  () => view === "week" || view === "day" || view === "days" || view === "resource"
)
const interactive = computed(() => view !== "agenda" && !props.preview)
const timedBlock = computed(() => inTimeGrid.value && !isBar.value)
const horizontalBar = computed(() => isBar.value && !inTimeGrid.value)
const stackedBlock = computed(
  () =>
    timedBlock.value &&
    (props.segment.endMin ?? 0) - (props.segment.startMin ?? 0) >=
      viewConfig.compactEventMinutes
)

const FADE_TRUNCATE =
  "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)]"

const startTimeLabel = computed(() =>
  format(
    toZoned(occurrence.value.start, settings.timeZone),
    settings.i18n.formats.eventTime,
    { locale: settings.locale }
  )
)
const rangeTimeLabel = computed(() =>
  settings.i18n.functions.formatEventTime(
    toZoned(occurrence.value.start, settings.timeZone),
    toZoned(occurrence.value.end, settings.timeZone),
    occurrence.value.allDay,
    { locale: settings.locale }
  )
)

// Agenda time text is per-day for multi-day events (see original comment).
const agendaTimeText = computed(() => {
  if (view !== "agenda") return ""
  if (occurrence.value.allDay) return settings.i18n.labels.allDay
  const dayStart = zonedStartOfDay(props.segment.day, settings.timeZone)
  const dayEnd = addDays(toZoned(dayStart, settings.timeZone), 1)
  const startsBefore = occurrence.value.start < dayStart
  const endsAfter = occurrence.value.end > dayEnd
  if (startsBefore && endsAfter) return settings.i18n.labels.allDay
  if (endsAfter) return settings.i18n.labels.timeFrom(startTimeLabel.value)
  if (startsBefore) {
    return settings.i18n.labels.timeUntil(
      format(
        toZoned(occurrence.value.end, settings.timeZone),
        settings.i18n.formats.eventTime,
        { locale: settings.locale }
      )
    )
  }
  return rangeTimeLabel.value
})

const timeLabel = rangeTimeLabel
const label = computed(() =>
  settings.i18n.functions.formatEventLabel
    ? settings.i18n.functions.formatEventLabel(event.value.title, timeLabel.value)
    : `${event.value.title}, ${timeLabel.value}`
)

const showResize = computed(
  () =>
    interactive.value &&
    resizeOn.value &&
    !event.value.readOnly &&
    event.value.resizable !== false
)

const chipStyle = computed(() => ({
  "--ec-event-color": event.value.color ?? "var(--color-primary)",
  ...(props.style as Record<string, string> | undefined),
}))

const ariaLabel = computed(
  () =>
    settings.i18n.functions.formatEventAriaLabel?.(
      event.value.title,
      timeLabel.value,
      props.segment.continuesBefore || props.segment.continuesAfter
    ) ??
    `${event.value.title}, ${timeLabel.value}${
      props.segment.continuesBefore || props.segment.continuesAfter
        ? `, ${settings.i18n.labels.continues}`
        : ""
    }`
)

function onPointerDown(e: PointerEvent) {
  e.stopPropagation()
  gestures.markChipPress()
  if (interactive.value) gestures.beginMove(e, props.segment)
}

function onClick(e: MouseEvent) {
  e.stopPropagation()
  if (gestures.wasRecentDrag()) return
  settings.onEventClick?.(occurrence.value, e)
  if (e.defaultPrevented || view === "agenda") return
  instance.api.selectEvent(occurrence.value.key)
}

function onDoubleClick(e: MouseEvent) {
  e.stopPropagation()
  settings.onEventDoubleClick?.(occurrence.value, e)
}
</script>

<template>
  <Primitive
    :ref="forwardRef"
    as="button"
    :as-child="asChild"
    type="button"
    data-slot="event-calendar-event"
    :data-view="view"
    :data-all-day="occurrence.allDay || undefined"
    :data-recurring="occurrence.isRecurring || undefined"
    :data-selected="isSelected || undefined"
    :data-dragging="isDragging || undefined"
    :data-preview="preview || undefined"
    :data-past="occurrence.end.getTime() < Date.now() || undefined"
    :title="preview ? undefined : label"
    :aria-label="ariaLabel"
    :aria-pressed="interactive ? isSelected : undefined"
    :aria-hidden="preview || undefined"
    :tabindex="preview ? -1 : undefined"
    :style="chipStyle"
    :class="
      cn(
        'group/ec-event text-foreground relative flex w-full min-w-0 cursor-pointer touch-none items-center overflow-hidden text-start select-none',
        'focus-visible:ring-ring/50 outline-none focus-visible:ring-2',
        preview && 'pointer-events-none',
        view === 'agenda'
          ? 'gap-3 rounded-md text-sm'
          : cn(
              '@container gap-1.5 rounded-sm px-1.5 py-1 leading-normal',
              'bg-(--ec-event-color)/15 hover:bg-(--ec-event-color)/25',
              'dark:bg-(--ec-event-color)/20 dark:hover:bg-(--ec-event-color)/30',
              'inset-ring inset-ring-(--ec-event-color)/15',
              'transition-[background-color,box-shadow] duration-150',
              'data-dragging:opacity-40',
              'data-selected:bg-(--ec-event-color)/30 data-selected:inset-ring-(--ec-event-color)/40',
              segment.continuesBefore && 'rounded-s-none',
              segment.continuesAfter && 'rounded-e-none'
            ),
        viewConfig.classNames?.event,
        props.class
      )
    "
    @pointerdown="onPointerDown"
    @click="onClick"
    @dblclick="onDoubleClick"
  >
    <slot>
      <template v-if="view === 'agenda'">
        <span class="text-muted-foreground w-40 shrink-0 truncate tabular-nums">{{ agendaTimeText }}</span>
        <span aria-hidden data-slot="event-calendar-agenda-dot" class="size-2 shrink-0 rounded-full bg-(--ec-event-color)" />
        <span class="truncate text-sm">{{ event.title }}</span>
        <svg v-if="occurrence.isRecurring" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground size-2.5 shrink-0" aria-hidden="true"><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></svg>
      </template>
      <template v-else>
        <span v-if="!timedBlock" aria-hidden data-slot="event-calendar-event-dot" class="-me-0.5 size-1.5 shrink-0 rounded-full bg-(--ec-event-color)" />
        <svg v-if="occurrence.isRecurring" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-2.5 shrink-0 opacity-70" aria-hidden="true"><path d="m17 2 4 4-4 4" /><path d="M3 11v-1a4 4 0 0 1 4-4h14" /><path d="m7 22-4-4 4-4" /><path d="M21 13v1a4 4 0 0 1-4 4H3" /></svg>
        <span :class="cn('font-medium', stackedBlock ? FADE_TRUNCATE : 'truncate')">{{ event.title }}</span>
        <template v-if="!occurrence.allDay && segment.isStart">
          <span v-if="view === 'month'" class="text-muted-foreground shrink-0">{{ startTimeLabel }}</span>
          <span v-else :class="cn('text-muted-foreground hidden @[8rem]:inline', stackedBlock ? FADE_TRUNCATE : 'truncate')">{{ rangeTimeLabel }}</span>
        </template>
      </template>
    </slot>
    <template v-if="showResize">
      <span
        v-if="timedBlock && segment.isStart"
        data-slot="event-calendar-resize-handle"
        data-edge="start"
        :class="cn('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', viewConfig.classNames?.resizeHandle)"
        @pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'start')"
      >
        <span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
      </span>
      <span
        v-if="timedBlock && segment.isEnd"
        data-slot="event-calendar-resize-handle"
        data-edge="end"
        :class="cn('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', viewConfig.classNames?.resizeHandle)"
        @pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'end')"
      >
        <span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
      </span>
      <span
        v-if="(horizontalBar || (isBar && inTimeGrid)) && segment.isStart"
        data-slot="event-calendar-resize-handle"
        data-edge="start"
        :class="cn('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', viewConfig.classNames?.resizeHandle)"
        @pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'start')"
      >
        <span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
      </span>
      <span
        v-if="(horizontalBar || (isBar && inTimeGrid)) && segment.isEnd"
        data-slot="event-calendar-resize-handle"
        data-edge="end"
        :class="cn('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', viewConfig.classNames?.resizeHandle)"
        @pointerdown="(e: PointerEvent) => gestures.beginResize(e, segment, 'end')"
      >
        <span aria-hidden data-slot="event-calendar-resize-grip" :class="cn('bg-foreground/40 rounded-full', timedBlock ? 'h-0.5 w-2.5' : 'h-2.5 w-0.5', viewConfig.classNames?.resizeGrip)" />
      </span>
    </template>
  </Primitive>
</template>

src/reui/event-calendar/EventCalendarMonthView.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarMonthView (event-calendar-month-view.tsx, MIT).
 * Первое представление — минимальное ядро для запуска визуального гейта.
 *
 * ponytail — сознательно НЕ перенесено в этом проходе (добавить по мере
 * появления кейсов, которые это используют):
 *  - drag/resize ghosts, inline drop placeholder, `data-drop-target` —
 *    зависят от `event-calendar-dnd.tsx`, которого ещё нет (план: последним).
 *  - "auto" measuring `maxEventsPerCell` через ResizeObserver — используется
 *    фиксированный fallback (3), как в оригинале при отсутствии измерения.
 *  - "+N more" — статичный неинтерактивный индикатор (`<span>`), а не Popover
 *    со списком скрытых событий (оригинал: `Popover`/`PopoverContent`).
 *  - week-number gutter, off-days marking, `showDayAddButton`, `renderMonthCell`
 *    и прочие render-пропы, `asChild`, фокус-реставрация после ре-ключевания
 *    чипа (`restoreChipFocus`, актуально только вместе с dnd).
 *  - `useEventCalendarViewSettings` (слияние view-level пропов с
 *    `state.viewSettings`) — weekends/weekNumbers/nowIndicator/offDays
 *    читаются прямо из `state.viewSettings` (root-level), без per-view
 *    プроп-оверрайдов (эта view не принимает `weekends`/`weekNumbers` пропы).
 *
 * Раскладка бар-оверлея (colStart/colSpan/lane) и упаковка НЕ пересчитываются
 * здесь — переиспользуются уже упакованные `index.weekRows[].bars` из lib.ts
 * (`packWeekRowLanes`), тот же источник, что и `useEventCalendarWeek` в
 * оригинале.
 */
import { computed, provide, type HTMLAttributes } from "vue"
import { addDays, format } from "date-fns"
import { cn } from "@/lib/utils"
import {
  EventCalendarViewContextKey,
  useEventCalendar,
  useEventCalendarSettings,
  useEventCalendarViewConfig,
} from "./context"
import { getDayKey, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { EventCalendarSegment } from "./types"

const props = defineProps<{
  class?: HTMLAttributes["class"]
  maxEventsPerCell?: number | "auto"
}>()

provide(EventCalendarViewContextKey, { view: "month" })

const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()

const visibleRange = computed(() => instance.state.value.visibleRange)
const anchorDate = computed(() => instance.state.value.date)
const weekendsOn = computed(() => instance.state.value.viewSettings.weekends ?? true)

const weeks = computed(() => {
  const days: Date[] = []
  let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
  while (cursor < visibleRange.value.end) {
    days.push(cursor)
    cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
  }
  const rows: Date[][] = []
  for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7))
  if (weekendsOn.value) return rows
  return rows.map((row) =>
    row.filter((day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()))
  )
})

const headerDays = computed(() => weeks.value[0] ?? [])
const title = computed(() =>
  settings.i18n.functions.formatTitle("month", {
    date: toZoned(anchorDate.value, settings.timeZone),
    activeRange: instance.api.getActiveRange(),
    visibleRange: visibleRange.value,
    locale: settings.locale,
  })
)
const gridTemplateColumns = computed(
  () => `repeat(${headerDays.value.length}, minmax(0, 1fr))`
)
const cap = computed(() => {
  const configured = props.maxEventsPerCell ?? viewConfig.maxEventsPerCell
  return configured === "auto" ? 3 : configured
})

function weekRowFor(weekStart: Date) {
  const startMs = zonedStartOfDay(weekStart, settings.timeZone).getTime()
  return instance.api
    .getIndex()
    .weekRows.find(
      (r) => zonedStartOfDay(r.rowStart, settings.timeZone).getTime() === startMs
    )
}

function dayBucket(day: Date): EventCalendarDayBucket {
  return (
    instance.api.getIndex().byDay.get(getDayKey(day, settings.timeZone)) ?? {
      allDay: [],
      timed: [],
    }
  )
}

function isToday(day: Date): boolean {
  return getDayKey(new Date(), settings.timeZone) === getDayKey(day, settings.timeZone)
}

function isOutside(day: Date): boolean {
  const dayStart = zonedStartOfDay(day, settings.timeZone)
  const active = instance.state.value.activeRange
  return dayStart < active.start || dayStart >= active.end
}

interface WeekLayout {
  week: Date[]
  offsets: number[]
  visibleBars: EventCalendarSegment[]
  gridPos: (colStart: number, colSpan: number) => { col: number; span: number } | null
  reservedLanes: (col: number) => number
  hiddenBarKeys: (col: number) => Set<string>
}

function layoutWeek(week: Date[]): WeekLayout {
  const rowStart = weekRowFor(week[0]!)?.rowStart ?? week[0]!
  const bars = weekRowFor(week[0]!)?.bars ?? []
  const rowStartMs = zonedStartOfDay(rowStart, settings.timeZone).getTime()
  const dayMs = 86400000
  const offsets = week.map((d) =>
    Math.round((zonedStartOfDay(d, settings.timeZone).getTime() - rowStartMs) / dayMs)
  )
  const gridPos = (colStart: number, colSpan: number) => {
    let start = -1
    let end = -1
    for (let o = colStart; o < colStart + colSpan; o++) {
      const col = offsets.indexOf(o)
      if (col === -1) continue
      if (start === -1) start = col
      end = col
    }
    return start === -1 ? null : { col: start, span: end - start + 1 }
  }
  const visibleBars = bars.filter((b) => (b.lane ?? 0) < cap.value)
  const covers = (b: EventCalendarSegment, dayOffset: number) =>
    (b.colStart ?? 0) <= dayOffset && dayOffset < (b.colStart ?? 0) + (b.colSpan ?? 1)
  return {
    week,
    offsets,
    visibleBars,
    gridPos,
    reservedLanes: (col) =>
      visibleBars.reduce(
        (max, b) => (covers(b, offsets[col]!) ? Math.max(max, (b.lane ?? 0) + 1) : max),
        0
      ),
    hiddenBarKeys: (col) =>
      new Set(
        bars
          .filter((b) => (b.lane ?? 0) >= cap.value && covers(b, offsets[col]!))
          .map((b) => b.occurrence.key)
      ),
  }
}

interface CellView {
  bucket: EventCalendarDayBucket
  visibleTimed: EventCalendarSegment[]
  overflowCount: number
}

function cellView(day: Date, layout: WeekLayout, col: number): CellView {
  const bucket = dayBucket(day)
  const hiddenBarKeys = layout.hiddenBarKeys(col)
  const reservedLanes = layout.reservedLanes(col)
  const timedSlots = Math.max(0, cap.value - reservedLanes)
  const visibleTimed = bucket.timed.slice(0, timedSlots)
  const overflowCount = hiddenBarKeys.size + Math.max(0, bucket.timed.length - timedSlots)
  return { bucket, visibleTimed, overflowCount }
}
</script>

<template>
  <div
    data-slot="event-calendar-month-view"
    data-view="month"
    role="grid"
    :aria-label="title"
    :class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.monthView, props.class)"
  >
    <div
      role="row"
      data-slot="event-calendar-month-header"
      :class="cn('@container grid border-b', viewConfig.classNames?.monthHeader)"
      :style="{ gridTemplateColumns }"
    >
      <div
        v-for="day in headerDays"
        :key="day.getTime()"
        role="columnheader"
        :class="cn('text-muted-foreground truncate px-2 py-1.5 font-medium', viewConfig.classNames?.monthDayHeader)"
      >
        <span class="@max-[36rem]:hidden">{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthDayHeader, { locale: settings.locale }) }}</span>
        <span class="hidden @max-[36rem]:inline">{{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthDayHeaderNarrow, { locale: settings.locale }) }}</span>
      </div>
    </div>
    <div
      data-slot="event-calendar-month-body"
      :class="cn('grid min-h-0 flex-1', viewConfig.classNames?.monthBody)"
      :style="{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }"
    >
      <div
        v-for="(week, rowIndex) in weeks"
        :key="rowIndex"
        role="row"
        data-slot="event-calendar-month-row"
        :class="cn('relative grid min-h-0 border-b last:border-b-0', viewConfig.classNames?.monthRow)"
        :style="{ gridTemplateColumns }"
      >
        <template v-for="(day, col) in week" :key="day.getTime()">
          <div
            role="gridcell"
            data-slot="event-calendar-month-cell"
            :data-today="isToday(day) || undefined"
            :data-outside="isOutside(day) || undefined"
            :data-weekend="settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay()) || undefined"
            :data-ec-day="zonedStartOfDay(day, settings.timeZone).getTime()"
            :aria-label="format(toZoned(day, settings.timeZone), settings.i18n.formats.monthCellAriaLabel, { locale: settings.locale })"
            :class="
              cn(
                'group/ec-cell relative flex min-h-0 min-w-0 flex-col overflow-hidden',
                col !== week.length - 1 && 'border-e',
                isOutside(day) && !settings.showOutsideDays && 'invisible',
                isToday(day) && cn('bg-primary/3 border-b-primary/40 relative border-b-2', viewConfig.todayClassName),
                viewConfig.classNames?.monthCell
              )
            "
            @click="settings.onSlotClick?.({ date: day, allDay: true, view: 'month' }, $event)"
          >
            <div :class="cn('flex min-h-0 flex-1 flex-col gap-0.5 overflow-hidden px-1 pt-1.5', viewConfig.classNames?.monthCellContent)">
              <div
                v-if="layoutWeek(week).reservedLanes(col) > 0"
                aria-hidden
                class="shrink-0"
                :style="{ height: `calc(${layoutWeek(week).reservedLanes(col)} * var(--ec-month-bar-h, 1.75rem) - 0.125rem)` }"
              />
              <EventCalendarEvent
                v-for="segment in cellView(day, layoutWeek(week), col).visibleTimed"
                :key="segment.occurrence.key"
                :segment="segment"
                class="shrink-0"
              />
              <span
                v-if="cellView(day, layoutWeek(week), col).overflowCount > 0"
                data-slot="event-calendar-more"
                :class="cn('text-muted-foreground shrink-0 truncate px-1 text-xs font-medium', viewConfig.classNames?.moreIndicator)"
              >
                {{ settings.i18n.labels.more(cellView(day, layoutWeek(week), col).overflowCount) }}
              </span>
            </div>
            <div :class="cn('flex items-center justify-end gap-1 px-2 pb-1.5', viewConfig.classNames?.monthCellFooter)">
              <span
                data-slot="event-calendar-month-day-number"
                :class="
                  cn(
                    'flex size-5 items-center justify-center rounded-full',
                    isOutside(day) && 'text-muted-foreground',
                    isToday(day) && 'bg-primary text-primary-foreground font-light',
                    viewConfig.classNames?.monthDayNumber
                  )
                "
              >
                {{ format(toZoned(day, settings.timeZone), settings.i18n.formats.monthCellDay, { locale: settings.locale }) }}
              </span>
            </div>
          </div>
        </template>
        <div
          v-if="layoutWeek(week).visibleBars.length > 0"
          data-slot="event-calendar-month-bar-overlay"
          class="pointer-events-none absolute inset-x-0 top-0 z-10 grid pt-1.5"
          :style="{ gridTemplateColumns, gridAutoRows: 'var(--ec-month-bar-h, 1.75rem)' }"
        >
          <template v-for="bar in layoutWeek(week).visibleBars" :key="bar.occurrence.key">
            <div
              v-if="layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)"
              :class="cn('pointer-events-auto min-w-0 px-1', viewConfig.classNames?.monthBar)"
              :style="{
                gridColumn: `${(layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)!.col) + 1} / span ${layoutWeek(week).gridPos(bar.colStart ?? 0, bar.colSpan ?? 1)!.span}`,
                gridRow: (bar.lane ?? 0) + 1,
              }"
            >
              <EventCalendarEvent :segment="bar" class="h-[calc(var(--ec-month-bar-h,1.75rem)-0.125rem)]" />
            </div>
          </template>
        </div>
      </div>
    </div>
  </div>
</template>

src/reui/event-calendar/EventCalendarNav.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarNav (event-calendar-nav.tsx, MIT) — Today/prev/next/
 * заголовок периода. Композиция составлена по образцу `EventCalendarNav`
 * оригинала (Today, prev/next, заголовок, growing spacer).
 *
 * ponytail — НЕ портировано в этом проходе (ни одного текущего кейса не
 * блокирует, ни один не переключает вид):
 *  - `EventCalendarViewSwitcher` (DropdownMenu со списком видов) и
 *    `EventCalendarDatePicker` (Popover+Calendar) — обе плавающие поверхности,
 *    требуют reui/dropdown-menu и ui/calendar интеграции сверху уже большого
 *    файла; добавить, когда появится кейс с несколькими видами/датапикером.
 *  - `EventCalendarToolbar` — тонкая обёртка-層 (`flex items-center gap-2`),
 *    добавить вместе с view switcher, когда понадобится слот тулбара.
 *  - Tooltip на каждой кнопке (`NavTooltip`/`viewConfig.navTooltips`) — не
 *    влияет на закрытый статичный рендер (TooltipTrigger asChild ничего не
 *    добавляет в DOM), пропущен ради объёма; добавить как обычный
 *    reui/tooltip враппер.
 *  - `asChild` на кнопках/заголовке/toolbar.
 * `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>` (пути
 * lucide "chevron-left"/"chevron-right" v0.545.0), тем же приёмом, что и
 * везде в порте.
 */
import { computed, type HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useEventCalendar, useEventCalendarSettings, useEventCalendarViewConfig } from "./context"
import { toZoned } from "./lib"

const props = defineProps<{ class?: HTMLAttributes["class"] }>()

const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()

const navVariant = computed(() => viewConfig.navButtonVariant)
const navSize = computed(() => viewConfig.navButtonSize)
const navIconSize = computed(() => (viewConfig.navButtonSize === "sm" ? "icon-sm" : "icon"))

const date = computed(() => instance.state.value.date)
const title = computed(() =>
  settings.i18n.functions.formatTitle(instance.state.value.view, {
    date: toZoned(date.value, settings.timeZone),
    activeRange: instance.state.value.activeRange,
    visibleRange: instance.state.value.visibleRange,
    locale: settings.locale,
  })
)
const isToday = computed(() => {
  const now = toZoned(new Date(), settings.timeZone)
  return now >= instance.state.value.activeRange.start && now < instance.state.value.activeRange.end
})
</script>

<template>
  <div
    data-slot="event-calendar-nav"
    :class="
      cn(
        'flex min-w-0 flex-wrap items-center gap-1 px-2 py-2',
        viewConfig.stickyNav && 'bg-background sticky top-0 z-30',
        viewConfig.classNames?.nav,
        props.class
      )
    "
  >
    <Button
      :variant="navVariant"
      :size="navSize"
      data-slot="event-calendar-nav-today"
      :data-active="isToday || undefined"
      :class="viewConfig.classNames?.navButton"
      @click="instance.api.today()"
    >
      {{ settings.i18n.labels.today }}
    </Button>
    <div class="flex items-center">
      <Button
        :variant="navVariant"
        :size="navIconSize"
        data-slot="event-calendar-nav-prev"
        :aria-label="settings.i18n.labels.previous"
        :class="viewConfig.classNames?.navButton"
        @click="instance.api.prev()"
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
      </Button>
      <Button
        :variant="navVariant"
        :size="navIconSize"
        data-slot="event-calendar-nav-next"
        :aria-label="settings.i18n.labels.next"
        :class="viewConfig.classNames?.navButton"
        @click="instance.api.next()"
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
      </Button>
    </div>
    <div data-slot="event-calendar-title" aria-live="polite" :class="cn('ms-3 min-w-0 truncate text-sm font-semibold', viewConfig.classNames?.title)">
      {{ title }}
    </div>
    <div class="grow" />
  </div>
</template>

src/reui/event-calendar/EventCalendarResourceView.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarResourceView (event-calendar-resource-view.tsx, MIT)
 * — колонки-ресурсы на один день (бронирования): одна часовая шкала, одна
 * колонка на лист-ресурс.
 *
 * ponytail — тот же список причин, что в EventCalendarTimeGrid.vue (dnd,
 * измерение реального DOM, не нужно ни одному текущему кейсу):
 *  - drag/resize ghosts, slot-draft, `data-drop-target` — ждут dnd.
 *  - `EventCalendarNowIndicator`, начальный скролл к `scrollToHour`,
 *    `--ec-scrollbar-w` — не перенесены (см. EventCalendarTimeGrid.vue).
 *  - off-days marking, render-пропы (`renderResourceHeader`/
 *    `renderAllDaySection`), `asChild`, `scrollbars: "native"` ветка.
 * `flattenResources`/`packTimedSegments` (lib.ts) переиспользованы как есть.
 * НЕ забыт `provide(EventCalendarViewContextKey, ...)` (§ найдено на
 * agenda-view: без него `EventCalendarEvent` бросает и роняет всё дерево —
 * см. docs/PORTING.md).
 */
import { computed, provide, type HTMLAttributes } from "vue"
import { format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  EventCalendarViewContextKey,
  useEventCalendar,
  useEventCalendarSettings,
  useEventCalendarViewConfig,
} from "./context"
import { flattenResources, getDayKey, getDayTotalMinutes, packTimedSegments, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarResource, EventCalendarSegment } from "./types"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    dayStartHour?: number
    dayEndHour?: number
    showAllDay?: boolean
    interval?: number
  }>(),
  { showAllDay: undefined, dayStartHour: undefined, dayEndHour: undefined, interval: undefined }
)

provide(EventCalendarViewContextKey, { view: "resource" })

const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()

const showAllDay = computed(() => props.showAllDay ?? true)
const startHour = computed(() => props.dayStartHour ?? settings.dayStartHour)
const endHour = computed(() => props.dayEndHour ?? settings.dayEndHour)
const interval = computed(() => Math.min(Math.max(props.interval ?? viewConfig.interval, 5), 240))
const day = computed(() => zonedStartOfDay(instance.state.value.date, settings.timeZone))

const resources = computed<EventCalendarResource[]>(() =>
  flattenResources(settings.resources)
    .filter(({ resource }) => !resource.children?.length)
    .map(({ resource }) => resource)
)

const slots = computed(() => {
  const result: number[] = []
  for (let m = startHour.value * 60; m < endHour.value * 60; m += interval.value) result.push(m)
  return result
})

const gridTemplateColumns = computed(() => `repeat(${resources.value.length || 1}, minmax(8rem, 1fr))`)

function dayBucketAllDay(): EventCalendarSegment[] {
  return instance.api.getIndex().byDay.get(getDayKey(day.value, settings.timeZone))?.allDay ?? []
}
function dayBucketTimed(): EventCalendarSegment[] {
  return instance.api.getIndex().byDay.get(getDayKey(day.value, settings.timeZone))?.timed ?? []
}

function allDayForResource(resourceId: string): EventCalendarSegment[] {
  return dayBucketAllDay().filter((segment) => segment.occurrence.event.resourceId === resourceId)
}

interface ColumnView {
  boundsStartMin: number
  boundsEndMin: number
  boundsMinutes: number
  packedTimed: EventCalendarSegment[]
}

function columnView(resourceId: string): ColumnView {
  const totalMinutes = getDayTotalMinutes(day.value, settings.timeZone)
  const boundsStartMin = startHour.value * 60
  const boundsEndMin = Math.min(endHour.value * 60, totalMinutes)
  const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
  const mine = dayBucketTimed()
    .filter((segment) => {
      if (segment.occurrence.event.resourceId !== resourceId) return false
      const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
      const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
      return endMin > boundsStartMin && startMin < boundsEndMin
    })
    .map((segment) => ({ ...segment }))
  packTimedSegments(mine)
  return { boundsStartMin, boundsEndMin, boundsMinutes, packedTimed: mine }
}

function minuteBlockStyle(startMin: number, endMin: number, boundsStartMin: number) {
  const top = (startMin - boundsStartMin) / 60
  const height = Math.max((endMin - startMin) / 60, 0.25)
  return {
    top: `calc(var(--ec-hour-height) * ${top})`,
    height: `calc(var(--ec-hour-height) * ${height})`,
  }
}
</script>

<template>
  <div
    data-slot="event-calendar-resource-view"
    data-view="resource"
    :class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.timeGrid, props.class)"
    :style="{ '--ec-hour-height': '4rem' }"
  >
    <div :class="cn('flex border-b', viewConfig.classNames?.timeGridHeader)">
      <div class="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
      <div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
        <div
          v-for="resource in resources"
          :key="resource.id"
          data-slot="event-calendar-resource-header"
          :class="cn('min-w-0 truncate border-e px-2 py-1.5 text-center font-medium last:border-e-0', viewConfig.classNames?.resourceHeader)"
        >
          {{ resource.title }}
        </div>
      </div>
    </div>
    <div v-if="showAllDay" data-slot="event-calendar-all-day-section" :class="cn('flex border-b', viewConfig.classNames?.allDaySection)">
      <div :class="cn('text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5', viewConfig.classNames?.allDayLabel)">
        <span class="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">{{ settings.i18n.labels.allDay }}</span>
      </div>
      <div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
        <div
          v-for="resource in resources"
          :key="resource.id"
          data-slot="event-calendar-all-day-cell"
          :class="cn('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', viewConfig.classNames?.allDayCell)"
        >
          <EventCalendarEvent
            v-for="segment in allDayForResource(resource.id)"
            :key="segment.occurrence.key"
            :segment="segment"
            class="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]"
          />
        </div>
      </div>
    </div>
    <div class="min-h-0 flex-1">
      <ScrollArea class="h-full">
        <div class="relative flex">
          <div data-slot="event-calendar-time-gutter" :class="cn('relative w-(--ec-gutter-width,4.5rem) shrink-0 border-e', viewConfig.classNames?.timeGutter)">
            <div
              v-for="minutes in slots"
              :key="minutes"
              class="relative"
              :style="{ height: `calc(var(--ec-hour-height) * ${interval / 60})` }"
            >
              <span
                v-if="minutes > startHour * 60"
                :class="cn('text-muted-foreground absolute end-2.5 -top-2', viewConfig.classNames?.timeGutterLabel)"
              >
                {{ format(toZoned(new Date(day.getTime() + minutes * 60000), settings.timeZone), interval % 60 === 0 ? settings.i18n.formats.timeGutter : settings.i18n.formats.timeGutterMinute, { locale: settings.locale }) }}
              </span>
            </div>
          </div>
          <div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
            <div
              v-for="resource in resources"
              :key="resource.id"
              data-slot="event-calendar-day-column"
              :aria-label="resource.title"
              role="group"
              :data-ec-day="day.getTime()"
              :data-ec-bounds-start="columnView(resource.id).boundsStartMin"
              :data-ec-bounds-end="columnView(resource.id).boundsEndMin"
              :data-ec-resource="resource.id"
              :class="cn('relative min-w-0 border-e last:border-e-0', viewConfig.classNames?.dayColumn)"
              :style="{
                height: `calc(var(--ec-hour-height) * ${columnView(resource.id).boundsMinutes / 60})`,
                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(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
              }"
            >
              <div
                v-for="segment in columnView(resource.id).packedTimed"
                :key="segment.occurrence.key"
                class="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
                :style="{
                  ...minuteBlockStyle(Math.max(segment.startMin ?? 0, columnView(resource.id).boundsStartMin), Math.min(segment.endMin ?? 0, columnView(resource.id).boundsEndMin), columnView(resource.id).boundsStartMin),
                  left: `${(segment.column ?? 0) * (100 / (segment.columnCount ?? 1))}%`,
                  width: `${(segment.columnSpan ?? 1) * (100 / (segment.columnCount ?? 1))}%`,
                  '--ec-z': segment.occurrence.event.zIndex ?? 10 + (segment.column ?? 0),
                }"
              >
                <EventCalendarEvent
                  :segment="segment"
                  :class="
                    cn(
                      (segment.columnCount ?? 1) > 1 && 'ring-background ring-1',
                      (segment.endMin ?? 0) - (segment.startMin ?? 0) < viewConfig.compactEventMinutes
                        ? 'h-full gap-1 py-0 leading-4'
                        : 'h-full flex-col items-start justify-start gap-0 py-1',
                      viewConfig.classNames?.timedChip
                    )
                  "
                />
              </div>
            </div>
          </div>
        </div>
      </ScrollArea>
    </div>
  </div>
</template>

src/reui/event-calendar/EventCalendarTimeGrid.vue

<script setup lang="ts">
/**
 * Порт ReUI EventCalendarTimeGrid (event-calendar-time-grid.tsx, MIT) —
 * общий движок week/day/N-days: часовая шкала, минутное позиционирование
 * событий, all-day строка.
 *
 * ponytail — сознательно НЕ перенесено в этом проходе (тот же список
 * причин, что в EventCalendarMonthView.vue: зависят либо от dnd, либо от
 * измерения реального DOM, либо не нужны ни одному текущему кейсу):
 *  - drag/resize ghosts, slot-draft — ждут `event-calendar-dnd.tsx`.
 *  - `EventCalendarNowIndicator` — линия "сейчас"; кейс (15 июня 2026)
 *    сознательно не пересекает реальную дату прогона, поэтому линия и так
 *    никогда не попала бы в видимый диапазон — добавить вместе с
 *    `useNow()` (setInterval-тик), когда появится кейс, где это видно.
 *  - `--ec-scrollbar-w`/`api.scrollToTime`/начальный скролл к `scrollToHour`
 *    (ResizeObserver + измерение реального DOM) — контейнер просто
 *    прокручивается нативно; начальная прокрутка к `scrollToHour` не
 *    выставляется.
 *  - off-days marking, `renderDayColumnBackground`/`renderTimeGutterSlot`/
 *    `renderAllDaySection`/`renderDayHeader` рендер-пропы, `asChild`,
 *    `scrollbars: "native"` ветка (всегда `ScrollArea`, кастомный скролл —
 *    дефолт оригинала).
 * `packTimedSegments` (lib.ts) переиспользован для перепаковки сегментов,
 * обрезанных границами часов (`boundsStartMin`/`boundsEndMin`), как и в
 * оригинале.
 */
import { computed, provide, type HTMLAttributes } from "vue"
import { addDays, addMinutes, format } from "date-fns"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  EventCalendarViewContextKey,
  useEventCalendar,
  useEventCalendarSettings,
  useEventCalendarViewConfig,
} from "./context"
import { getDayKey, getDayTotalMinutes, packTimedSegments, toZoned, zonedStartOfDay } from "./lib"
import EventCalendarEvent from "./EventCalendarEvent.vue"
import type { EventCalendarDayBucket } from "./lib"
import type { CalendarView, EventCalendarSegment } from "./types"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    view: Extract<CalendarView, "week" | "day" | "days">
    dayStartHour?: number
    dayEndHour?: number
    showAllDay?: boolean
    interval?: number
  }>(),
  { showAllDay: true, dayStartHour: undefined, dayEndHour: undefined, interval: undefined }
)

provide(EventCalendarViewContextKey, { view: props.view })

const instance = useEventCalendar()
const settings = useEventCalendarSettings()
const viewConfig = useEventCalendarViewConfig()

const visibleRange = computed(() => instance.state.value.visibleRange)
const weekendsOn = computed(() => instance.state.value.viewSettings.weekends ?? true)
const startHour = computed(() => props.dayStartHour ?? settings.dayStartHour)
const endHour = computed(() => props.dayEndHour ?? settings.dayEndHour)
const interval = computed(() => Math.min(Math.max(props.interval ?? viewConfig.interval, 5), 240))

const days = computed(() => {
  const result: Date[] = []
  let cursor = zonedStartOfDay(visibleRange.value.start, settings.timeZone)
  while (cursor < visibleRange.value.end) {
    result.push(cursor)
    cursor = zonedStartOfDay(addDays(toZoned(cursor, settings.timeZone), 1), settings.timeZone)
  }
  if (weekendsOn.value || props.view === "day") return result
  const filtered = result.filter(
    (day) => !settings.weekendDays.includes(toZoned(day, settings.timeZone).getDay())
  )
  return filtered.length ? filtered : result
})

const slots = computed(() => {
  const result: number[] = []
  for (let m = startHour.value * 60; m < endHour.value * 60; m += interval.value) result.push(m)
  return result
})

const gridTemplateColumns = computed(() => `repeat(${days.value.length}, minmax(0, 1fr))`)

function isToday(day: Date): boolean {
  return getDayKey(new Date(), settings.timeZone) === getDayKey(day, settings.timeZone)
}

function dayBucket(day: Date): EventCalendarDayBucket {
  return (
    instance.api.getIndex().byDay.get(getDayKey(day, settings.timeZone)) ?? { allDay: [], timed: [] }
  )
}

// All-day bars: consecutive-day segments of the same occurrence merged into
// one bar spanning its columns, lane-packed (same treatment as month view).
const allDayBars = computed(() => {
  type Bar = { seg: EventCalendarSegment; colStart: number; colEnd: number; isStart: boolean; isEnd: boolean; lane: number }
  const merged = new Map<string, Bar>()
  days.value.forEach((day, col) => {
    for (const seg of dayBucket(day).allDay) {
      const key = seg.occurrence.key
      const bar = merged.get(key)
      if (bar) {
        bar.colEnd = col
        bar.isEnd = seg.isEnd
      } else {
        merged.set(key, { seg, colStart: col, colEnd: col, isStart: seg.isStart, isEnd: seg.isEnd, lane: 0 })
      }
    }
  })
  const packed = Array.from(merged.values()).sort(
    (a, b) =>
      a.colStart - b.colStart ||
      b.colEnd - b.colStart - (a.colEnd - a.colStart) ||
      a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
  )
  const lanes: boolean[][] = []
  for (const bar of packed) {
    let lane = 0
    for (;;) {
      const row = (lanes[lane] ??= new Array(days.value.length).fill(false))
      let free = true
      for (let c = bar.colStart; c <= bar.colEnd; c++) {
        if (row[c]) {
          free = false
          break
        }
      }
      if (free) break
      lane++
    }
    const row = lanes[lane]!
    for (let c = bar.colStart; c <= bar.colEnd; c++) row[c] = true
    bar.lane = lane
  }
  return {
    bars: packed.map((bar) => ({
      ...bar.seg,
      isStart: bar.isStart,
      isEnd: bar.isEnd,
      continuesBefore: !bar.isStart,
      continuesAfter: !bar.isEnd,
      colStart: bar.colStart,
      colSpan: bar.colEnd - bar.colStart + 1,
      lane: bar.lane,
    })),
    laneCount: lanes.length,
  }
})

interface DayColumnView {
  isToday: boolean
  boundsStartMin: number
  boundsEndMin: number
  boundsMinutes: number
  packedTimed: EventCalendarSegment[]
}

function dayColumnView(day: Date): DayColumnView {
  const totalMinutes = getDayTotalMinutes(day, settings.timeZone)
  const boundsStartMin = startHour.value * 60
  const boundsEndMin = Math.min(endHour.value * 60, totalMinutes)
  const boundsMinutes = Math.max(60, boundsEndMin - boundsStartMin)
  const timed = dayBucket(day).timed
  const visible = timed.filter((segment) => {
    const startMin = Math.max(segment.startMin ?? 0, boundsStartMin)
    const endMin = Math.min(segment.endMin ?? startMin, boundsEndMin)
    return endMin > boundsStartMin && startMin < boundsEndMin
  })
  let packedTimed = visible
  if (visible.length !== timed.length) {
    packedTimed = visible.map((segment) => ({ ...segment }))
    packTimedSegments(packedTimed)
  }
  return { isToday: isToday(day), boundsStartMin, boundsEndMin, boundsMinutes, packedTimed }
}

function minuteBlockStyle(startMin: number, endMin: number, boundsStartMin: number) {
  const top = (startMin - boundsStartMin) / 60
  const height = Math.max((endMin - startMin) / 60, 0.25)
  return {
    top: `calc(var(--ec-hour-height) * ${top})`,
    height: `calc(var(--ec-hour-height) * ${height})`,
  }
}
</script>

<template>
  <div
    data-slot="event-calendar-time-grid"
    :data-view="view"
    :class="cn('flex min-h-0 flex-1 flex-col overflow-hidden border-t', viewConfig.classNames?.timeGrid, props.class)"
    :style="{ '--ec-hour-height': '4rem' }"
  >
    <div :class="cn('flex border-b', viewConfig.classNames?.timeGridHeader)">
      <div class="w-(--ec-gutter-width,4.5rem) shrink-0 border-e" />
      <div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
        <div
          v-for="day in days"
          :key="day.getTime()"
          data-slot="event-calendar-day-header"
          :data-today="isToday(day) || undefined"
          :class="cn('data-today:text-primary min-w-0 truncate border-e px-2 py-1.5 font-medium last:border-e-0', isToday(day) && viewConfig.todayClassName)"
        >
          {{ format(toZoned(day, settings.timeZone), settings.i18n.formats.timeGridDayHeader, { locale: settings.locale }) }}
        </div>
      </div>
    </div>
    <div v-if="showAllDay" data-slot="event-calendar-all-day-section" :class="cn('flex border-b', viewConfig.classNames?.allDaySection)">
      <div :class="cn('text-muted-foreground w-(--ec-gutter-width,4.5rem) shrink-0 border-e ps-2 pe-2.5 pt-1.5', viewConfig.classNames?.allDayLabel)">
        <span class="flex h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)] items-center justify-end">{{ settings.i18n.labels.allDay }}</span>
      </div>
      <div class="relative min-w-0 flex-1">
        <div
          class="grid h-full"
          :style="{ gridTemplateColumns, minHeight: `calc(${Math.max(allDayBars.laneCount, 1)} * var(--ec-month-bar-h, 1.625rem) + 0.625rem)` }"
        >
          <div v-for="day in days" :key="day.getTime()" class="border-e last:border-e-0" />
        </div>
        <div
          v-if="allDayBars.bars.length > 0"
          data-slot="event-calendar-all-day-bar-overlay"
          class="pointer-events-none absolute inset-x-0 top-0 grid pt-1.5"
          :style="{ gridTemplateColumns, gridAutoRows: 'var(--ec-month-bar-h, 1.625rem)' }"
        >
          <div
            v-for="segment in allDayBars.bars"
            :key="segment.occurrence.key"
            class="pointer-events-auto min-w-0 px-1"
            :style="{ gridColumn: `${(segment.colStart ?? 0) + 1} / span ${segment.colSpan ?? 1}`, gridRow: (segment.lane ?? 0) + 1 }"
          >
            <EventCalendarEvent :segment="segment" class="h-[calc(var(--ec-month-bar-h,1.625rem)-0.125rem)]" />
          </div>
        </div>
      </div>
    </div>
    <div class="min-h-0 flex-1">
      <ScrollArea class="h-full">
        <div class="relative flex">
          <div data-slot="event-calendar-time-gutter" :class="cn('relative w-(--ec-gutter-width,4.5rem) shrink-0 border-e', viewConfig.classNames?.timeGutter)">
            <div
              v-for="minutes in slots"
              :key="minutes"
              class="relative"
              :style="{ height: `calc(var(--ec-hour-height) * ${interval / 60})` }"
            >
              <span
                v-if="minutes > startHour * 60"
                :class="cn('text-muted-foreground absolute end-2.5 -top-2', viewConfig.classNames?.timeGutterLabel)"
              >
                {{ format(addMinutes(zonedStartOfDay(days[0] ?? new Date(), settings.timeZone), minutes), interval % 60 === 0 ? settings.i18n.formats.timeGutter : settings.i18n.formats.timeGutterMinute, { locale: settings.locale }) }}
              </span>
            </div>
          </div>
          <div class="grid min-w-0 flex-1" :style="{ gridTemplateColumns }">
            <div
              v-for="day in days"
              :key="day.getTime()"
              data-slot="event-calendar-day-column"
              :data-today="dayColumnView(day).isToday || undefined"
              :data-ec-day="zonedStartOfDay(day, settings.timeZone).getTime()"
              :data-ec-bounds-start="dayColumnView(day).boundsStartMin"
              :data-ec-bounds-end="dayColumnView(day).boundsEndMin"
              :class="cn('relative min-w-0 border-e last:border-e-0', dayColumnView(day).isToday && viewConfig.todayClassName, viewConfig.classNames?.dayColumn)"
              :style="{
                height: `calc(var(--ec-hour-height) * ${dayColumnView(day).boundsMinutes / 60})`,
                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(--color-border)) calc(var(--ec-hour-height) * ${interval / 60} - var(--ec-slot-line-width, 1px)), var(--ec-slot-line-color, var(--color-border)) calc(var(--ec-hour-height) * ${interval / 60}))`,
              }"
            >
              <template v-for="segment in dayColumnView(day).packedTimed" :key="segment.occurrence.key">
                <div
                  v-if="!(Math.min(segment.endMin ?? 0, dayColumnView(day).boundsEndMin) <= dayColumnView(day).boundsStartMin || Math.max(segment.startMin ?? 0, dayColumnView(day).boundsStartMin) >= dayColumnView(day).boundsEndMin)"
                  class="absolute z-(--ec-z) min-h-(--ec-event-min-h,1.5rem) px-0.5 hover:z-40"
                  :style="{
                    ...minuteBlockStyle(Math.max(segment.startMin ?? 0, dayColumnView(day).boundsStartMin), Math.min(segment.endMin ?? 0, dayColumnView(day).boundsEndMin), dayColumnView(day).boundsStartMin),
                    left: `${(segment.column ?? 0) * (100 / (segment.columnCount ?? 1))}%`,
                    width: `${(segment.columnSpan ?? 1) * (100 / (segment.columnCount ?? 1))}%`,
                    '--ec-z': segment.occurrence.event.zIndex ?? 10 + (segment.column ?? 0),
                  }"
                >
                  <EventCalendarEvent
                    :segment="segment"
                    :class="
                      cn(
                        (segment.columnCount ?? 1) > 1 && 'ring-background ring-1',
                        (segment.endMin ?? 0) - (segment.startMin ?? 0) < viewConfig.compactEventMinutes
                          ? 'h-full gap-1 py-0 leading-4'
                          : 'h-full flex-col items-start justify-start gap-0 py-1',
                        viewConfig.classNames?.timedChip
                      )
                    "
                  />
                </div>
              </template>
            </div>
          </div>
        </div>
      </ScrollArea>
    </div>
  </div>
</template>

src/reui/event-calendar/EventCalendarWeekView.vue

<script setup lang="ts">
/** Тонкая обёртка над EventCalendarTimeGrid с фиксированным `view="week"`, как в оригинале. */
import type { HTMLAttributes } from "vue"
import EventCalendarTimeGrid from "./EventCalendarTimeGrid.vue"

// showAllDay: undefined в withDefaults — иначе Vue приводит отсутствующий
// boolean-проп к `false` вместо `undefined` и глушит дефолт `true` из
// EventCalendarTimeGrid.vue (та же ловушка, что задокументирована в
// docs/PORTING.md §32 / EventCalendar.vue).
const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    dayStartHour?: number
    dayEndHour?: number
    showAllDay?: boolean
    interval?: number
  }>(),
  { showAllDay: undefined }
)
</script>

<template>
  <EventCalendarTimeGrid
    view="week"
    :class="props.class"
    :day-start-hour="props.dayStartHour"
    :day-end-hour="props.dayEndHour"
    :show-all-day="props.showAllDay"
    :interval="props.interval"
  />
</template>

src/reui/event-calendar/context.ts

/**
 * Порт движка ReUI EventCalendar (event-calendar.tsx, MIT) поверх Vue
 * reactivity — НЕ порт `useSyncExternalStore`/`createEventCalendarStore`, а
 * переписывание (тот же приём, что и DataGrid, см. комментарий в
 * `data-grid/context.ts`): в оригинале `getState()`/`subscribe()`/ручной
 * `snapshot`/`indexCache`/`rangeCache` существуют только затем, чтобы дать
 * React стабильный по ссылке снимок между рендерами — Vue `computed()` делает
 * это бесплатно (кэшируется, пока не изменились реактивные зависимости).
 *
 * ponytail: v1 — только неуправляемый (uncontrolled) режим: `createEventCalendar`
 * читает `options` один раз при создании (как React `default*`-пропы), без
 * `value`/`onXChange`-пары и без реакции на смену пропов извне. Двусторонний
 * `v-model` на каждое поле — задача корневого `EventCalendar.vue` (ещё не
 * портирован); добавить, когда он появится.
 *
 * ponytail: `EventCalendarGestures` (move/resize/create) здесь — заглушка
 * (no-op). Настоящая реализация — `event-calendar-dnd.tsx`, портируется
 * последним по плану. `EventCalendarEvent.vue` уже вызывает
 * `useEventCalendarGestures()`, чтобы не переписывать её потом: корневой
 * компонент вызовет `provide(EventCalendarGesturesContextKey, ...)` с боевой
 * реализацией, ничего в этом файле/потребителях менять не придётся.
 */
import type { Component, ComputedRef, InjectionKey, VNodeChild } from "vue"
import { computed, inject, shallowReactive } from "vue"
import type { Locale } from "date-fns"
import {
  buildEventIndex,
  defaultEventOrder,
  eventsOverlap,
  getDayKey,
  getViewDateRange,
  stepDate,
  toZoned as toZonedLib,
  type EventCalendarIndex,
  type WeekStartsOn,
} from "./lib"
import {
  mergeEventCalendarI18n,
  type EventCalendarI18nConfig,
  type EventCalendarI18nOverrides,
} from "./i18n"
import type {
  CalendarEvent,
  CalendarView,
  EventCalendarDateRange,
  EventCalendarDragState,
  EventCalendarEventId,
  EventCalendarInteractions,
  EventCalendarOccurrence,
  EventCalendarOffDaysConfig,
  EventCalendarProposedUpdate,
  EventCalendarResource,
  EventCalendarSegment,
  EventCalendarSelection,
  EventCalendarSlotDraft,
  EventCalendarSlotInfo,
  EventCalendarState,
  EventCalendarUpdateResult,
  EventCalendarViewSettings,
} from "./types"

const BASE_VIEWS: CalendarView[] = ["month", "week", "day", "days", "agenda"]
const ALL_VIEWS: CalendarView[] = [...BASE_VIEWS, "resource"]

const DEFAULT_INTERACTIONS: EventCalendarInteractions = {
  drag: true,
  resize: true,
  selectSlot: true,
}

const EMPTY_SELECTION: EventCalendarSelection = { eventKeys: [], slot: null }
const DEFAULT_WEEKEND_DAYS: number[] = [0, 6]
const EMPTY_RESOURCES: EventCalendarResource[] = []
const DEFAULT_EVENT_PRIORITY = (event: CalendarEvent<never>) =>
  event.priority ?? 0

type EventOrder<TData> = (
  a: EventCalendarOccurrence<TData>,
  b: EventCalendarOccurrence<TData>
) => number

function priorityEventOrder<TData>(
  getEventPriority: (event: CalendarEvent<TData>) => number
): EventOrder<TData> {
  return (a, b) =>
    getEventPriority(b.event) - getEventPriority(a.event) ||
    defaultEventOrder(a, b)
}

export interface EventCalendarActivationConfig {
  moveDistancePx: number
  createDistancePx: number
  touchDelayMs: number
  touchTolerancePx: number
  autoScrollEdgePx: number
  autoScrollMaxStepPx: number
}

export interface EventCalendarCallbacks<TData = unknown> {
  onEventClick?: (
    occurrence: EventCalendarOccurrence<TData>,
    e: MouseEvent
  ) => void
  onEventDoubleClick?: (
    occurrence: EventCalendarOccurrence<TData>,
    e: MouseEvent
  ) => void
  onEventUpdate?: (
    update: EventCalendarProposedUpdate<TData>
  ) => EventCalendarUpdateResult
  canDropEvent?: (update: EventCalendarProposedUpdate<TData>) => boolean
  onDragBlocked?: (
    occurrence: EventCalendarOccurrence<TData>,
    info: {
      gesture: "move" | "resize"
      reason: "readOnly" | "disabled" | "interactions-off"
    }
  ) => void
  onSlotClick?: (slot: EventCalendarSlotInfo, e: MouseEvent) => void
  onSelectSlot?: (slot: EventCalendarSlotDraft) => void
  canSelectSlot?: (slot: EventCalendarSlotDraft) => boolean
  onViewChange?: (view: CalendarView) => void
  onDateChange?: (date: Date) => void
  onMoreClick?: (
    day: Date,
    segments: EventCalendarOccurrence<TData>[],
    e: MouseEvent
  ) => void | false
}

/** Input to `createEventCalendar` — uncontrolled-only initial values (see file header). */
export interface EventCalendarOptions<TData = unknown>
  extends EventCalendarCallbacks<TData> {
  events?: CalendarEvent<TData>[]
  view?: CalendarView
  date?: Date
  dayCount?: number
  selection?: EventCalendarSelection
  interactions?: Partial<EventCalendarInteractions>
  viewSettings?: EventCalendarViewSettings
  loading?: boolean
  views?: CalendarView[]
  timeZone?: string
  locale?: Locale
  weekStartsOn?: WeekStartsOn
  dayStartHour?: number
  dayEndHour?: number
  slotDuration?: number
  snapDuration?: number
  agendaDayCount?: number
  fixedWeeks?: boolean
  showOutsideDays?: boolean
  i18n?: EventCalendarI18nOverrides
  resources?: EventCalendarResource[]
  getEventPriority?: (event: CalendarEvent<TData>) => number
  eventOrder?: (
    a: EventCalendarOccurrence<TData>,
    b: EventCalendarOccurrence<TData>
  ) => number
  getOccurrences?: (
    event: CalendarEvent<TData>,
    range: EventCalendarDateRange,
    ctx: { timeZone: string }
  ) => Array<{ start: Date; end: Date }> | null
  weekendDays?: number[]
  activation?: Partial<EventCalendarActivationConfig>
}

export interface EventCalendarSettings<TData = unknown>
  extends EventCalendarCallbacks<TData> {
  timeZone: string
  locale?: Locale
  weekStartsOn: WeekStartsOn
  views: CalendarView[]
  dayStartHour: number
  dayEndHour: number
  slotDuration: number
  snapDuration: number
  agendaDayCount: number
  fixedWeeks: boolean
  showOutsideDays: boolean
  i18n: EventCalendarI18nConfig
  resources: EventCalendarResource[]
  weekendDays: number[]
  activation?: Partial<EventCalendarActivationConfig>
  getEventPriority: (event: CalendarEvent<TData>) => number
  eventOrder: (
    a: EventCalendarOccurrence<TData>,
    b: EventCalendarOccurrence<TData>
  ) => number
  getOccurrences?: (
    event: CalendarEvent<TData>,
    range: EventCalendarDateRange,
    ctx: { timeZone: string }
  ) => Array<{ start: Date; end: Date }> | null
}

function resolveSettings<TData>(
  options: EventCalendarOptions<TData>
): EventCalendarSettings<TData> {
  const {
    events: _events,
    view: _view,
    date: _date,
    dayCount: _dayCount,
    selection: _selection,
    interactions: _interactions,
    viewSettings: _viewSettings,
    loading: _loading,
    ...rest
  } = options
  const getEventPriority =
    options.getEventPriority ??
    (DEFAULT_EVENT_PRIORITY as (event: CalendarEvent<TData>) => number)
  return {
    ...rest,
    timeZone:
      options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
    locale: options.locale,
    weekStartsOn:
      options.weekStartsOn ?? options.locale?.options?.weekStartsOn ?? 0,
    views:
      options.views ?? (options.resources?.length ? ALL_VIEWS : BASE_VIEWS),
    dayStartHour: options.dayStartHour ?? 0,
    dayEndHour: options.dayEndHour ?? 24,
    slotDuration: options.slotDuration ?? 30,
    snapDuration: options.snapDuration ?? 15,
    agendaDayCount: options.agendaDayCount ?? 30,
    fixedWeeks: options.fixedWeeks ?? true,
    showOutsideDays: options.showOutsideDays ?? true,
    i18n: mergeEventCalendarI18n(options.i18n),
    resources: options.resources ?? EMPTY_RESOURCES,
    getEventPriority,
    eventOrder: options.eventOrder ?? priorityEventOrder(getEventPriority),
    getOccurrences: options.getOccurrences,
    weekendDays: options.weekendDays ?? DEFAULT_WEEKEND_DAYS,
    activation: options.activation,
  }
}

export interface EventCalendarApi<TData = unknown> {
  next(): void
  prev(): void
  today(): void
  goTo(date: Date): void
  setView(view: CalendarView, opts?: { dayCount?: number }): void
  setDayCount(count: number): void
  getEvents(): CalendarEvent<TData>[]
  getEvent(id: EventCalendarEventId): CalendarEvent<TData> | undefined
  setEvents(events: CalendarEvent<TData>[]): void
  addEvent(event: CalendarEvent<TData>): void
  updateEvent(
    id: EventCalendarEventId,
    patch: Partial<CalendarEvent<TData>>
  ): void
  removeEvent(id: EventCalendarEventId): void
  getOccurrences(
    range?: EventCalendarDateRange
  ): EventCalendarOccurrence<TData>[]
  getOccurrencesForDay(day: Date): EventCalendarOccurrence<TData>[]
  findOverlapping(candidate: {
    start: Date
    end: Date
    excludeEventId?: string
  }): EventCalendarOccurrence<TData>[]
  select(selection: Partial<EventCalendarSelection>): void
  selectEvent(key: string, opts?: { additive?: boolean }): void
  clearSelection(): void
  setInteractions(patch: Partial<EventCalendarInteractions>): void
  setViewSettings(patch: EventCalendarViewSettings): void
  getVisibleRange(): EventCalendarDateRange
  getActiveRange(): EventCalendarDateRange
  /** TZDate in the calendar's display time zone. */
  toZoned(date: Date): Date
  applyProposedUpdate(
    update: EventCalendarProposedUpdate<TData>,
    extraPatch?: Partial<CalendarEvent<TData>>
  ): boolean
  getIndex(): EventCalendarIndex<TData>
  /** Cross-file plumbing for dnd.ts (live drag/resize preview); not part of the original public API. */
  setDrag(drag: EventCalendarDragState<TData> | null): void
  setSlotDraft(draft: EventCalendarSlotDraft | null): void
}

export interface EventCalendarInstance<TData = unknown> {
  state: ComputedRef<EventCalendarState<TData>>
  api: EventCalendarApi<TData>
  settings: EventCalendarSettings<TData>
}

/**
 * The full engine: reactive internal fields + derived `state`, matching
 * `createEventCalendarStore`'s snapshot shape field-for-field (see file
 * header for why there is no separate snapshot/subscribe machinery here).
 */
export function createEventCalendar<TData = unknown>(
  options: EventCalendarOptions<TData> = {}
): EventCalendarInstance<TData> {
  const settings = resolveSettings(options)

  const resolveView = (view: CalendarView): CalendarView =>
    settings.views.includes(view) ? view : (settings.views[0] ?? "month")

  const internal = shallowReactive({
    view: resolveView(options.view ?? "month"),
    date: options.date ?? new Date(),
    dayCount: Math.max(1, options.dayCount ?? 3),
    events: (options.events ?? []) as CalendarEvent<TData>[],
    selection: options.selection ?? EMPTY_SELECTION,
    interactions: { ...DEFAULT_INTERACTIONS, ...options.interactions },
    viewSettings: options.viewSettings ?? {},
    loading: options.loading ?? false,
    drag: null as EventCalendarDragState<TData> | null,
    slotDraft: null as EventCalendarSlotDraft | null,
  })

  const state: ComputedRef<EventCalendarState<TData>> = computed(() => {
    const view = resolveView(internal.view)
    const dayCount = Math.max(1, internal.dayCount)
    const { visibleRange, activeRange } = getViewDateRange(view, internal.date, {
      timeZone: settings.timeZone,
      weekStartsOn: settings.weekStartsOn,
      dayCount,
      agendaDayCount: settings.agendaDayCount,
      fixedWeeks: settings.fixedWeeks,
    })
    return {
      view,
      date: internal.date,
      dayCount,
      visibleRange,
      activeRange,
      events: internal.events,
      selection: internal.selection,
      interactions: internal.interactions,
      loading: internal.loading,
      drag: internal.drag,
      slotDraft: internal.slotDraft,
      viewSettings: internal.viewSettings,
    }
  })

  const index = computed(() =>
    buildEventIndex(state.value.events, state.value.visibleRange, {
      timeZone: settings.timeZone,
      weekStartsOn: settings.weekStartsOn,
      eventOrder: settings.eventOrder,
      getOccurrences: settings.getOccurrences,
    })
  )

  const remapSelectionKey = (
    id: EventCalendarEventId,
    oldKey: string,
    nextStart: Date
  ) => {
    const newKey = `${id}::${nextStart.toISOString()}`
    if (newKey === oldKey) return
    if (!internal.selection.eventKeys.includes(oldKey)) return
    internal.selection = {
      ...internal.selection,
      eventKeys: internal.selection.eventKeys.map((key) =>
        key === oldKey ? newKey : key
      ),
    }
  }

  const applyProposedUpdate = (
    update: EventCalendarProposedUpdate<TData>,
    extraPatch?: Partial<CalendarEvent<TData>>
  ): boolean => {
    const result = settings.onEventUpdate?.(update)
    if (result === false) return false
    const adjusted: Partial<CalendarEvent<TData>> =
      result && typeof result === "object"
        ? {
            start: result.start ?? update.start,
            end: result.end ?? update.end,
            allDay: result.allDay ?? update.allDay,
          }
        : { start: update.start, end: update.end, allDay: update.allDay }
    if (update.resourceId !== undefined) adjusted.resourceId = update.resourceId
    const stored = internal.events.find((event) => event.id === update.event.id)
    const oldKey =
      update.occurrence?.key ??
      (stored ? `${stored.id}::${stored.start.toISOString()}` : null)
    if (oldKey) {
      remapSelectionKey(update.event.id, oldKey, adjusted.start ?? update.start)
    }
    internal.events = internal.events.map((event) =>
      event.id === update.event.id
        ? { ...event, ...extraPatch, ...adjusted }
        : event
    )
    return true
  }

  const api: EventCalendarApi<TData> = {
    next() {
      internal.date = stepDate(state.value.view, internal.date, 1, {
        timeZone: settings.timeZone,
        dayCount: state.value.dayCount,
        agendaDayCount: settings.agendaDayCount,
      })
      settings.onDateChange?.(internal.date)
    },
    prev() {
      internal.date = stepDate(state.value.view, internal.date, -1, {
        timeZone: settings.timeZone,
        dayCount: state.value.dayCount,
        agendaDayCount: settings.agendaDayCount,
      })
      settings.onDateChange?.(internal.date)
    },
    today() {
      internal.date = new Date()
      settings.onDateChange?.(internal.date)
    },
    goTo(date) {
      internal.date = date
      settings.onDateChange?.(date)
    },
    setView(view, opts) {
      if (opts?.dayCount !== undefined) {
        internal.dayCount = Math.max(1, opts.dayCount)
      }
      internal.view = resolveView(view)
      settings.onViewChange?.(internal.view)
    },
    setDayCount(count) {
      internal.dayCount = Math.max(1, count)
    },
    getEvents() {
      return internal.events
    },
    getEvent(id) {
      return internal.events.find((event) => event.id === id)
    },
    setEvents(events) {
      internal.events = events
    },
    addEvent(event) {
      internal.events = [...internal.events, event]
    },
    updateEvent(id, patch) {
      const event = api.getEvent(id)
      if (!event) return
      const merged = { ...event, ...patch }
      const timingChanged =
        patch.start !== undefined ||
        patch.end !== undefined ||
        patch.allDay !== undefined
      if (timingChanged && settings.onEventUpdate) {
        const rest = { ...patch }
        delete rest.start
        delete rest.end
        delete rest.allDay
        applyProposedUpdate(
          {
            event: merged,
            occurrence: null,
            start: merged.start,
            end: merged.end,
            allDay: merged.allDay ?? false,
            source: "api",
          },
          rest
        )
        return
      }
      if (timingChanged) {
        remapSelectionKey(id, `${id}::${event.start.toISOString()}`, merged.start)
      }
      internal.events = internal.events.map((e) => (e.id === id ? merged : e))
    },
    removeEvent(id) {
      internal.events = internal.events.filter((event) => event.id !== id)
    },
    getOccurrences(range) {
      if (!range) return index.value.occurrences
      const within =
        range.start >= state.value.visibleRange.start &&
        range.end <= state.value.visibleRange.end
      if (within) {
        return index.value.occurrences.filter((occ) => eventsOverlap(occ, range))
      }
      // ponytail: no throwaway-range cache (see createEventCalendarStore's
      // rangeCache) — Vue callers read this through a computed of their own
      // when they need referential stability; add a cache here if a caller
      // ends up polling getOccurrences(range) every render.
      return buildEventIndex(internal.events, range, {
        timeZone: settings.timeZone,
        weekStartsOn: settings.weekStartsOn,
        eventOrder: settings.eventOrder,
        getOccurrences: settings.getOccurrences,
      }).occurrences
    },
    getOccurrencesForDay(day) {
      const bucket = index.value.byDay.get(getDayKey(day, settings.timeZone))
      if (!bucket) return []
      const seen = new Set<string>()
      const result: EventCalendarOccurrence<TData>[] = []
      for (const seg of [...bucket.allDay, ...bucket.timed]) {
        if (seen.has(seg.occurrence.key)) continue
        seen.add(seg.occurrence.key)
        result.push(seg.occurrence)
      }
      return result
    },
    findOverlapping({ start, end, excludeEventId }) {
      return api
        .getOccurrences({ start, end })
        .filter((occ) => occ.eventId !== excludeEventId)
    },
    select(partial) {
      internal.selection = {
        eventKeys: partial.eventKeys ?? internal.selection.eventKeys,
        slot: partial.slot !== undefined ? partial.slot : internal.selection.slot,
      }
    },
    selectEvent(key, opts) {
      const current = internal.selection
      const eventKeys = opts?.additive
        ? current.eventKeys.includes(key)
          ? current.eventKeys.filter((k) => k !== key)
          : [...current.eventKeys, key]
        : [key]
      internal.selection = { ...current, eventKeys }
    },
    clearSelection() {
      internal.selection = EMPTY_SELECTION
    },
    setInteractions(patch) {
      internal.interactions = { ...internal.interactions, ...patch }
    },
    setViewSettings(patch) {
      internal.viewSettings = { ...internal.viewSettings, ...patch }
    },
    getVisibleRange() {
      return state.value.visibleRange
    },
    getActiveRange() {
      return state.value.activeRange
    },
    toZoned(date) {
      return toZonedLib(date, settings.timeZone)
    },
    applyProposedUpdate,
    getIndex() {
      return index.value
    },
    setDrag(drag) {
      internal.drag = drag
    },
    setSlotDraft(draft) {
      internal.slotDraft = draft
    },
  }

  return { state, api, settings }
}

export const EventCalendarContextKey: InjectionKey<EventCalendarInstance<any>> =
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  Symbol("EventCalendarContext")

/** Соответствует `useEventCalendar()` оригинала — бросает вне `<EventCalendar>`. */
export function useEventCalendar<TData = unknown>(): EventCalendarInstance<TData> {
  const instance = inject(EventCalendarContextKey, undefined)
  if (!instance) {
    throw new Error("useEventCalendar must be used within <EventCalendar>")
  }
  return instance as EventCalendarInstance<TData>
}

/** Convenience accessor — `instance.settings` is a plain resolved object (v1: not per-prop reactive, see file header). */
export function useEventCalendarSettings<
  TData = unknown,
>(): EventCalendarSettings<TData> {
  return useEventCalendar<TData>().settings
}

export const EventCalendarViewContextKey: InjectionKey<{ view: CalendarView }> =
  Symbol("EventCalendarViewContext")

/** Соответствует `useEventCalendarViewContext()` оригинала — бросает вне вида. */
export function useEventCalendarViewContext(): { view: CalendarView } {
  const ctx = inject(EventCalendarViewContextKey, undefined)
  if (!ctx) {
    throw new Error(
      "useEventCalendarViewContext must be used inside a calendar view"
    )
  }
  return ctx
}

// ---------------------------------------------------------------------------
// View-layer config: display props + render overrides (EventCalendarViewConfig)
// ---------------------------------------------------------------------------

export interface EventCalendarClassNames {
  nav?: string
  toolbar?: string
  content?: string
  monthView?: string
  monthCell?: string
  timeGrid?: string
  timeGutter?: string
  dayColumn?: string
  allDaySection?: string
  agendaView?: string
  event?: string
  eventTooltip?: string
  moreIndicator?: string
  morePopover?: string
  morePopoverHeader?: string
  navButton?: string
  title?: string
  navTooltip?: string
  viewSwitcherContent?: string
  viewSwitcherLabel?: string
  viewShortcut?: string
  datePickerContent?: string
  monthHeader?: string
  monthDayHeader?: string
  monthBody?: string
  monthRow?: string
  weekNumber?: string
  monthBarOverlay?: string
  monthBar?: string
  monthCellContent?: string
  monthCellFooter?: string
  monthDayNumber?: string
  dayAddButton?: string
  timeGridHeader?: string
  timeGutterLabel?: string
  allDayLabel?: string
  allDayCell?: string
  timedChip?: string
  resourceHeader?: string
  dragGhost?: string
  dragCarry?: string
  dragCarryInvalid?: string
  dropHint?: string
  dropIndicator?: string
  slotDraft?: string
  resizeHandle?: string
  resizeGrip?: string
  noEvents?: string
  agendaDay?: string
  agendaDayHeader?: string
  agendaDayGutter?: string
  agendaDate?: string
  agendaDayToggle?: string
  agendaDayContent?: string
  agendaItem?: string
  agendaItemSurface?: string
  agendaItemToggle?: string
  agendaDaySummary?: string
  agendaSummaryDot?: string
}

export interface EventCalendarRenderEventProps<TData = unknown> {
  occurrence: EventCalendarOccurrence<TData>
  segment: EventCalendarSegment<TData>
  view: CalendarView
  isDragging: boolean
  isSelected: boolean
}

/** View-layer configuration: display props and render overrides. */
export interface EventCalendarViewConfig<TData = unknown> {
  scrollToHour: number
  nowIndicator: boolean
  interval: number
  maxEventsPerCell: number | "auto"
  showWeekNumbers: boolean
  enableShortcuts: boolean
  shortcutsScope: "focus-within" | "global"
  scrollMode: "contained" | "page"
  stickyNav: boolean
  dayClassName?: (day: Date) => string | undefined
  todayClassName?: string
  showDayAddButton: boolean
  scrollbars: "custom" | "native"
  navButtonVariant: "ghost" | "outline" | "secondary" | "default"
  navButtonSize: "sm" | "default"
  offDays?: boolean | EventCalendarOffDaysConfig
  classNames?: EventCalendarClassNames
  components?: Partial<Record<CalendarView, Component>>
  renderEvent?: (props: EventCalendarRenderEventProps<TData>) => VNodeChild
  renderAgendaEvent?: (props: EventCalendarRenderEventProps<TData>) => VNodeChild
  renderEventTooltip?: (props: {
    occurrence: EventCalendarOccurrence<TData>
    segment: EventCalendarSegment<TData>
    view: CalendarView
    label: string | undefined
  }) => VNodeChild
  renderDragPreview?: (props: { drag: EventCalendarDragState<TData> }) => VNodeChild
  renderMonthCell?: (props: {
    day: Date
    isToday: boolean
    isOutside: boolean
    overflowCount: number
    defaultContent: VNodeChild
  }) => VNodeChild
  renderDayColumnBackground?: (props: {
    day: Date
    boundsStartMin: number
    boundsEndMin: number
    totalMinutes: number
  }) => VNodeChild
  renderDayHeader?: (props: {
    day: Date
    view: CalendarView
    isToday: boolean
  }) => VNodeChild
  renderTimeGutterSlot?: (props: {
    time: Date
    hour: number
    minute: number
  }) => VNodeChild
  renderAllDaySection?: (props: {
    days: Date[]
    segments: EventCalendarSegment<TData>[]
  }) => VNodeChild
  renderMoreIndicator?: (props: {
    day: Date
    count: number
    segments: EventCalendarSegment<TData>[]
  }) => VNodeChild
  renderMoreContent?: (props: {
    day: Date
    segments: EventCalendarSegment<TData>[]
    close: () => void
  }) => VNodeChild
  renderAgendaEventDetails?: (
    occurrence: EventCalendarOccurrence<TData>
  ) => VNodeChild
  renderNowIndicator?: (props: { time: Date }) => VNodeChild
  renderNoEvents?: () => VNodeChild
  renderResourceHeader?: (props: { resource: EventCalendarResource }) => VNodeChild
  renderAgendaDayHeader?: (props: {
    day: Date
    collapsed: boolean
    count: number
    toggle: () => void
    defaultContent: VNodeChild
  }) => VNodeChild
  renderAgendaDaySummary?: (props: {
    day: Date
    occurrences: EventCalendarOccurrence<TData>[]
    count: number
    expand: () => void
    defaultContent: VNodeChild
  }) => VNodeChild
  dayCountPresets: number[]
  navTooltips?:
    | false
    | {
        side?: "top" | "bottom" | "left" | "right"
        delay?: number
        closeDelay?: number
        timeout?: number
      }
  eventTooltip?:
    | boolean
    | {
        side?: "top" | "bottom" | "left" | "right"
        delay?: number
      }
  compactEventMinutes: number
  morePopoverAlign: "start" | "center" | "end"
  nowIndicatorInterval: number
  agendaSummaryMaxDots: number
}

export const DEFAULT_EVENT_CALENDAR_VIEW_CONFIG: EventCalendarViewConfig = {
  scrollToHour: 7,
  nowIndicator: true,
  interval: 60,
  maxEventsPerCell: "auto",
  showWeekNumbers: false,
  enableShortcuts: true,
  shortcutsScope: "focus-within",
  scrollMode: "contained",
  stickyNav: false,
  showDayAddButton: false,
  scrollbars: "custom",
  navButtonVariant: "ghost",
  navButtonSize: "sm",
  dayCountPresets: [5],
  eventTooltip: false,
  compactEventMinutes: 45,
  morePopoverAlign: "start",
  nowIndicatorInterval: 30_000,
  agendaSummaryMaxDots: 6,
}

export const EventCalendarViewConfigContextKey: InjectionKey<
  EventCalendarViewConfig<any>
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
> = Symbol("EventCalendarViewConfigContext")

/** Соответствует `useEventCalendarViewConfig()` оригинала — не бросает, дефолт `DEFAULT_EVENT_CALENDAR_VIEW_CONFIG`. */
export function useEventCalendarViewConfig<
  TData = unknown,
>(): EventCalendarViewConfig<TData> {
  return inject(
    EventCalendarViewConfigContextKey,
    DEFAULT_EVENT_CALENDAR_VIEW_CONFIG
  ) as EventCalendarViewConfig<TData>
}

// ---------------------------------------------------------------------------
// Gestures — placeholder until event-calendar-dnd.tsx is ported (see file header)
// ---------------------------------------------------------------------------

export interface EventCalendarGestures<TData = unknown> {
  canResize: boolean
  beginMove: (e: PointerEvent, segment: EventCalendarSegment<TData>) => void
  beginResize: (
    e: PointerEvent,
    segment: EventCalendarSegment<TData>,
    edge: "start" | "end"
  ) => void
  beginCreate: (e: PointerEvent, day: Date, allDay: boolean) => void
  /** Suppresses the trailing click after a real drag; see `event-calendar-dnd.tsx`'s `wasRecentDrag`. */
  wasRecentDrag: () => boolean
  /** Marks a chip pointerdown so a non-drag press does not trigger create-on-click; see `markChipPress`. */
  markChipPress: () => void
}

function noopGestures<TData = unknown>(): EventCalendarGestures<TData> {
  return {
    canResize: false,
    beginMove: () => {},
    beginResize: () => {},
    beginCreate: () => {},
    wasRecentDrag: () => false,
    markChipPress: () => {},
  }
}

export const EventCalendarGesturesContextKey: InjectionKey<
  EventCalendarGestures<any>
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
> = Symbol("EventCalendarGesturesContext")

/** Соответствует `useEventCalendarGestures()` оригинала — не бросает, дефолт no-op до портирования dnd. */
export function useEventCalendarGestures<
  TData = unknown,
>(): EventCalendarGestures<TData> {
  return inject(
    EventCalendarGesturesContextKey,
    noopGestures,
    true
  ) as EventCalendarGestures<TData>
}

src/reui/event-calendar/dnd.ts

/**
 * Порт ReUI event-calendar-dnd.tsx (MIT) — переиспользуемая композиция
 * pointer-event хендлеров на голом DOM.
 *
 * ВАЖНО, отличается от Kanban/Sortable/DataGrid: оригинал `event-calendar-
 * dnd.tsx` НЕ использует dnd-kit вообще — это уже framework-агностичный
 * движок на `pointerdown`/`pointermove`/`pointerup`, window-листенерах и
 * ручной геометрии (`getBoundingClientRect`). ADR-002 (dnd-kit -> Pragmatic
 * Drag and Drop) сюда НЕ применяется: заменять нечего, переносится
 * оригинальная pointer-математика почти дословно, framework-специфичная
 * прослойка — только вызов `instance.api.*` вместо React setState и
 * `provide()` вместо React Context.
 *
 * ponytail — сознательно НЕ перенесено (граница эквивалентности ADR-002
 * прямо разрешает расхождение в "физике"/визуальной обратной связи, только
 * итоговая дата/модель обязаны совпасть):
 *  - Курсор-клон события (`createCarry`/`positionCarry`) и подсказка
 *    "нельзя бросить" (`updateHint`) — vanilla-DOM оверлеи, чисто
 *    визуальные, не влияют на itogovoe состояние. Добавить как есть из
 *    оригинала, если понадобится визуальное качество перетаскивания.
 *  - Автоскролл контейнера (`autoScroll`) — у наших кейсов вся сетка
 *    помещается без прокрутки; `pointerMinutes` здесь не компенсирует
 *    scrollTop (дельта всегда 0).
 *  - Touch/long-press активация (`touchDelayMs`) — интеракционный гейт
 *    проверяет только мышь; активация происходит сразу по dnd-порогу.
 *  - `beginBlockedGesture` (визуальный "not-allowed" на readOnly/disabled
 *    событии) — ни один текущий кейс не имеет readOnly-событий;
 *    `canDrag`/`canResize` просто отказывают без обратной связи.
 *  - Drag-create (выделение слота пустым перетаскиванием) — `beginCreate`
 *    существует и работает, но ни один вид (`MonthView`/`TimeGrid`/
 *    `ResourceView`) пока не вызывает его из `pointerdown` пустой ячейки
 *    (там сейчас только `onSlotClick` по клику) — добавить вызов
 *    `gestures.beginCreate` в `onPointerDown` дня/колонки, когда появится
 *    кейс с созданием событий перетаскиванием.
 * `computeProposal` (слой координата -> дата/время, дискретный по дням и
 * непрерывный по минутам) перенесён дословно — это ядро, которое обязано
 * дать идентичный итог с оригиналом.
 */
import type {
  EventCalendarInstance,
  EventCalendarGestures,
} from "./context"
import { snapMinutes, toZoned, zonedStartOfDay } from "./lib"
import type {
  EventCalendarProposedUpdate,
  EventCalendarSegment,
} from "./types"
import { addDays, addMinutes, differenceInCalendarDays } from "date-fns"

const EVENT_CALENDAR_ACTIVATION = {
  moveDistancePx: 5,
  createDistancePx: 4,
} as const

type GestureKind = "move" | "resize-start" | "resize-end" | "create"

interface TimeColumnRect {
  day: Date
  rect: DOMRect
  boundsStartMin: number
  boundsEndMin: number
  resourceId?: string
}

interface DayCellRect {
  day: Date
  rect: DOMRect
}

interface Surface {
  columns: TimeColumnRect[]
  cells: DayCellRect[]
}

let lastGestureEndedAt = 0
function wasRecentDrag(): boolean {
  return performance.now() - lastGestureEndedAt < 250
}

let lastChipPressAt = 0
function markChipPress(): void {
  lastChipPressAt = performance.now()
  window.addEventListener(
    "pointerup",
    () => {
      lastChipPressAt = performance.now()
    },
    { once: true, capture: true }
  )
}
function wasRecentChipPress(): boolean {
  return performance.now() - lastChipPressAt < 300
}

const activeGestureCancels = new Set<() => void>()
function cancelActiveEventCalendarGestures(): void {
  for (const cancel of [...activeGestureCancels]) cancel()
}

function collectSurface(origin: HTMLElement): Surface {
  const root =
    origin.closest<HTMLElement>("[data-slot=event-calendar-time-grid]") ??
    origin.closest<HTMLElement>("[data-slot=event-calendar-resource-view]") ??
    origin.closest<HTMLElement>("[data-slot=event-calendar-month-view]") ??
    origin.closest<HTMLElement>("[data-slot=event-calendar]") ??
    null

  const columns: TimeColumnRect[] = []
  const cells: DayCellRect[] = []
  if (root) {
    for (const el of root.querySelectorAll<HTMLElement>("[data-ec-day]")) {
      const day = new Date(Number(el.dataset.ecDay))
      if (el.dataset.ecBoundsStart !== undefined) {
        columns.push({
          day,
          rect: el.getBoundingClientRect(),
          boundsStartMin: Number(el.dataset.ecBoundsStart),
          boundsEndMin: Number(el.dataset.ecBoundsEnd),
          resourceId: el.dataset.ecResource,
        })
      } else {
        cells.push({ day, rect: el.getBoundingClientRect() })
      }
    }
  }
  return { columns, cells }
}

function findColumn(surface: Surface, clientX: number): TimeColumnRect | undefined {
  let best: TimeColumnRect | undefined
  for (const col of surface.columns) {
    if (clientX >= col.rect.left && clientX < col.rect.right) return col
    if (!best) best = col
    const bestDist = Math.min(Math.abs(clientX - best.rect.left), Math.abs(clientX - best.rect.right))
    const dist = Math.min(Math.abs(clientX - col.rect.left), Math.abs(clientX - col.rect.right))
    if (dist < bestDist) best = col
  }
  return best
}

function findCell(surface: Surface, x: number, y: number): DayCellRect | undefined {
  return surface.cells.find(
    (cell) => x >= cell.rect.left && x < cell.rect.right && y >= cell.rect.top && y < cell.rect.bottom
  )
}

// ponytail: no scroll-delta compensation (see file header) — the grid never
// scrolls in our cases, so the raw client Y maps straight onto the column.
function pointerMinutes(col: TimeColumnRect, clientY: number): number {
  const boundsMinutes = col.boundsEndMin - col.boundsStartMin
  const pxPerMinute = col.rect.height / Math.max(1, boundsMinutes)
  const y = clientY - col.rect.top
  return col.boundsStartMin + y / pxPerMinute
}

interface BeginGestureConfig<TData> {
  instance: EventCalendarInstance<TData>
  kind: GestureKind
  origin: HTMLElement
  startEvent: PointerEvent
  segment?: EventCalendarSegment<TData>
  createDay?: Date
  createAllDay?: boolean
}

function beginGesture<TData>(config: BeginGestureConfig<TData>) {
  const { instance, kind, origin, startEvent, segment } = config
  const { settings, api } = instance
  const timeZone = settings.timeZone
  const snap = settings.snapDuration
  const activation = { ...EVENT_CALENDAR_ACTIVATION, ...settings.activation }
  const startX = startEvent.clientX
  const startY = startEvent.clientY
  const pointerId = startEvent.pointerId

  let active = kind.startsWith("resize")
  let surface: Surface | null = active ? collectSurface(origin) : null
  let lastProposalKey = ""

  const occurrence = segment?.occurrence
  const isBar = occurrence
    ? occurrence.allDay || occurrence.end.getTime() - occurrence.start.getTime() > 24 * 60 * 60 * 1000
    : false

  // Preserve the grab offset so the event does not jump to the pointer.
  let grabOffsetMin = 0
  let grabLeadMs = 0
  let grabSegDurationMs = occurrence ? occurrence.end.getTime() - occurrence.start.getTime() : 0
  let grabDayOffset = 0

  const activationDistance = kind === "create" ? activation.createDistancePx : activation.moveDistancePx

  const activate = () => {
    if (active) return
    active = true
    surface = collectSurface(origin)
    if (kind === "move" && occurrence) {
      const grabCell = findCell(surface, startX, startY)
      if (grabCell) {
        const originDay = zonedStartOfDay(occurrence.start, timeZone)
        const lastDay = zonedStartOfDay(
          new Date(Math.max(occurrence.end.getTime() - 1, occurrence.start.getTime())),
          timeZone
        )
        const offset = differenceInCalendarDays(zonedStartOfDay(grabCell.day, timeZone), originDay)
        if (offset >= 0 && offset <= differenceInCalendarDays(lastDay, originDay)) {
          grabDayOffset = offset
        }
      }
      if (surface.columns.length > 0 && !isBar) {
        const col = findColumn(surface, startX)
        if (col) {
          const colDayStart = zonedStartOfDay(col.day, timeZone)
          const segStartMs = Math.max(occurrence.start.getTime(), colDayStart.getTime())
          grabLeadMs = segStartMs - occurrence.start.getTime()
          grabSegDurationMs = Math.min(occurrence.end.getTime(), addDays(colDayStart, 1).getTime()) - segStartMs
          grabOffsetMin = pointerMinutes(col, startY) - (segStartMs - colDayStart.getTime()) / 60000
        }
      }
    }
  }

  let createAnchorMin: number | null = null

  const computeProposal = (
    e: PointerEvent
  ): { start: Date; end: Date; allDay: boolean; dayGranular?: boolean; resourceId?: string } | null => {
    if (!surface) return null

    // ---- create: select a slot range
    if (kind === "create") {
      if (config.createAllDay || surface.columns.length === 0) {
        const anchor = zonedStartOfDay(config.createDay!, timeZone)
        const cell = findCell(surface, e.clientX, e.clientY)
        const target = cell ? zonedStartOfDay(cell.day, timeZone) : anchor
        const start = anchor <= target ? anchor : target
        const end = addDays(anchor <= target ? target : anchor, 1)
        return { start, end, allDay: true, dayGranular: true }
      }
      const col = findColumn(surface, startX)
      if (!col) return null
      if (createAnchorMin === null) createAnchorMin = snapMinutes(pointerMinutes(col, startY), snap)
      const anchorMin = createAnchorMin
      const curMin = snapMinutes(pointerMinutes(col, e.clientY), snap)
      const lo = Math.max(col.boundsStartMin, Math.min(anchorMin, curMin))
      const hi = Math.min(col.boundsEndMin, Math.max(anchorMin, curMin, lo + snap))
      const dayStart = zonedStartOfDay(col.day, timeZone)
      return { start: addMinutes(dayStart, lo), end: addMinutes(dayStart, hi), allDay: false, resourceId: col.resourceId }
    }

    if (!occurrence) return null
    const durationMs = occurrence.end.getTime() - occurrence.start.getTime()

    // ---- day-granularity: month cells and bars in the all-day row
    const overCell = findCell(surface, e.clientX, e.clientY)

    if (kind === "move" && !isBar && surface.columns.length > 0 && overCell !== undefined) {
      const targetDay = zonedStartOfDay(overCell.day, timeZone)
      return { start: targetDay, end: addDays(targetDay, 1), allDay: true, dayGranular: true }
    }

    const useCells = surface.columns.length === 0 || (isBar && overCell !== undefined)

    if (useCells) {
      const cell = overCell ?? findCell(surface, startX, startY)
      if (!cell) return null
      const targetDay = zonedStartOfDay(cell.day, timeZone)
      if (kind === "move") {
        const originDay = zonedStartOfDay(occurrence.start, timeZone)
        const delta = differenceInCalendarDays(targetDay, originDay) - grabDayOffset
        const start = addDays(toZoned(occurrence.start, timeZone), delta)
        return { start, end: new Date(start.getTime() + durationMs), allDay: occurrence.allDay, dayGranular: true }
      }
      if (kind === "resize-start") {
        const time = occurrence.start.getTime() - zonedStartOfDay(occurrence.start, timeZone).getTime()
        const start = new Date(targetDay.getTime() + time)
        if (start >= occurrence.end) return null
        return { start, end: occurrence.end, allDay: occurrence.allDay, dayGranular: true }
      }
      const time = occurrence.allDay ? 0 : occurrence.end.getTime() - zonedStartOfDay(occurrence.end, timeZone).getTime()
      const end = occurrence.allDay
        ? addDays(targetDay, 1)
        : new Date(addDays(targetDay, time > 0 ? 0 : 1).getTime() + time)
      if (end <= occurrence.start) return null
      return { start: occurrence.start, end, allDay: occurrence.allDay, dayGranular: true }
    }

    // ---- minute-granularity: time-grid columns
    const col = findColumn(surface, e.clientX)
    if (!col) return null
    const dayStart = zonedStartOfDay(col.day, timeZone)
    const rawMin = pointerMinutes(col, e.clientY)

    if (kind === "move") {
      const newStartMin = snapMinutes(rawMin - grabOffsetMin, snap)
      const chipDurationMin = Math.round(grabSegDurationMs / 60000)
      const clamped = Math.min(Math.max(newStartMin, col.boundsStartMin), col.boundsEndMin - chipDurationMin)
      const start = new Date(addMinutes(dayStart, clamped).getTime() - grabLeadMs)
      return { start, end: new Date(start.getTime() + durationMs), allDay: false, resourceId: col.resourceId }
    }

    const min = snapMinutes(rawMin, snap)
    const occStartMinInCol = Math.round((occurrence.start.getTime() - dayStart.getTime()) / 60000)
    const occEndMinInCol = Math.round((occurrence.end.getTime() - dayStart.getTime()) / 60000)
    if (kind === "resize-start") {
      const clamped = Math.min(Math.max(min, col.boundsStartMin), Math.min(occEndMinInCol - snap, col.boundsEndMin))
      const start = addMinutes(dayStart, clamped)
      if (start >= occurrence.end) return null
      return { start, end: occurrence.end, allDay: false }
    }
    const clamped = Math.max(Math.min(min, col.boundsEndMin), Math.max(occStartMinInCol + snap, col.boundsStartMin))
    const end = addMinutes(dayStart, clamped)
    if (end <= occurrence.start) return null
    return { start: occurrence.start, end, allDay: false }
  }

  const applyProposal = (e: PointerEvent) => {
    const proposal = computeProposal(e)
    if (!proposal) return
    const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? ""}`
    if (key === lastProposalKey) return
    lastProposalKey = key

    if (kind === "create") {
      const draft = { ...proposal, view: instance.state.value.view }
      if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return
      api.setSlotDraft(draft)
      return
    }
    const update: EventCalendarProposedUpdate<TData> = {
      event: occurrence!.event,
      occurrence: occurrence!,
      ...proposal,
      source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
    }
    const valid = settings.canDropEvent ? settings.canDropEvent(update) : true
    api.setDrag({
      kind: kind === "move" ? "move" : (kind as "resize-start" | "resize-end"),
      occurrence: occurrence!,
      proposedStart: proposal.start,
      proposedEnd: proposal.end,
      proposedAllDay: proposal.allDay,
      proposedDayGranular: proposal.dayGranular ?? false,
      proposedResourceId: proposal.resourceId,
      valid,
    })
  }

  let finished = false
  const cleanup = () => {
    if (finished) return
    finished = true
    activeGestureCancels.delete(cancel)
    window.removeEventListener("pointermove", onPointerMove)
    window.removeEventListener("pointerup", onPointerUp)
    window.removeEventListener("pointercancel", onCancel)
    window.removeEventListener("blur", onWindowBlur)
    window.removeEventListener("keydown", onKeyDown, true)
  }

  const cancel = () => {
    cleanup()
    if (active) {
      lastGestureEndedAt = performance.now()
      api.setDrag(null)
      api.setSlotDraft(null)
    }
  }

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") {
      e.stopPropagation()
      cancel()
    }
  }
  const onWindowBlur = () => cancel()

  const onPointerMove = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return
    if (!active) {
      const distance = Math.hypot(e.clientX - startX, e.clientY - startY)
      if (distance < activationDistance) return
      activate()
    }
    applyProposal(e)
  }

  const onPointerUp = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return
    cleanup()
    if (!active) return
    lastGestureEndedAt = performance.now()

    const state = instance.state.value
    if (kind === "create") {
      const draft = state.slotDraft
      api.setSlotDraft(null)
      if (draft) {
        api.select({ slot: { start: draft.start, end: draft.end, allDay: draft.allDay } })
        settings.onSelectSlot?.(draft)
      }
      return
    }
    const drag = state.drag
    api.setDrag(null)
    if (!drag || !occurrence) return
    const unchanged =
      drag.proposedStart.getTime() === occurrence.start.getTime() &&
      drag.proposedEnd.getTime() === occurrence.end.getTime() &&
      (drag.proposedResourceId === undefined || drag.proposedResourceId === occurrence.event.resourceId)
    if (unchanged) return
    api.applyProposedUpdate({
      event: occurrence.event,
      occurrence,
      start: drag.proposedStart,
      end: drag.proposedEnd,
      allDay: drag.proposedAllDay,
      resourceId: drag.proposedResourceId,
      source: kind === "move" ? "drag" : (kind as "resize-start" | "resize-end"),
    })
  }

  const onCancel = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return
    cancel()
  }

  window.addEventListener("pointermove", onPointerMove)
  window.addEventListener("pointerup", onPointerUp)
  window.addEventListener("pointercancel", onCancel)
  if (active) window.addEventListener("blur", onWindowBlur)
  window.addEventListener("keydown", onKeyDown, true)
  activeGestureCancels.add(cancel)
}

/** Соответствует `useEventCalendarGestures()` оригинала — построен один раз в `EventCalendar.vue` и провайден через `EventCalendarGesturesContextKey`. */
export function createEventCalendarGestures<TData = unknown>(
  instance: EventCalendarInstance<TData>
): EventCalendarGestures<TData> {
  const canDrag = (segment: EventCalendarSegment<TData>): boolean => {
    const { interactions } = instance.state.value
    const event = segment.occurrence.event
    return interactions.drag && !event.readOnly && event.draggable !== false
  }
  const canResizeSegment = (segment: EventCalendarSegment<TData>): boolean => {
    const { interactions } = instance.state.value
    const event = segment.occurrence.event
    return interactions.resize && !event.readOnly && event.resizable !== false
  }

  return {
    // ponytail: static `true` (see EventCalendarGestures — Event.vue does not
    // read this field; it derives handle visibility from state.interactions
    // itself). Kept only to satisfy the interface.
    canResize: true,
    beginMove(e, segment) {
      if (e.button !== 0 || !canDrag(segment)) return
      beginGesture({ instance, kind: "move", origin: e.currentTarget as HTMLElement, startEvent: e, segment })
    },
    beginResize(e, segment, edge) {
      if (e.button !== 0 || !canResizeSegment(segment)) return
      // Must not bubble: the resize handle is a DOM child of the chip's own
      // button, which has its own pointerdown -> beginMove listener (see
      // EventCalendarEvent.vue). Without this, grabbing the handle starts a
      // move AND a resize gesture at once.
      e.stopPropagation()
      e.preventDefault()
      beginGesture({
        instance,
        kind: edge === "start" ? "resize-start" : "resize-end",
        origin: e.currentTarget as HTMLElement,
        startEvent: e,
        segment,
      })
    },
    beginCreate(e, day, allDay) {
      if (e.button !== 0 || !instance.state.value.interactions.selectSlot) return
      beginGesture({ instance, kind: "create", origin: e.currentTarget as HTMLElement, startEvent: e, createDay: day, createAllDay: allDay })
    },
    wasRecentDrag,
    markChipPress,
  }
}

export { cancelActiveEventCalendarGestures, wasRecentChipPress }

src/reui/event-calendar/i18n.ts

// Title: Event Calendar I18n
// Description: Default UI texts, date-format strings, and formatter functions for the event calendar, fully overridable per key.
// Port of ReUI event-calendar-i18n.tsx (MIT) — pure TS/date-fns, ported verbatim.

import type { CalendarView, EventCalendarDateRange } from "./types"
import {
  format,
  isSameMonth,
  isSameYear,
  subMilliseconds,
  type Locale,
} from "date-fns"

interface EventCalendarI18nConfig {
  labels: {
    today: string
    previous: string
    next: string
    addEvent: string
    allDay: string
    more: (count: number) => string
    noEvents: string
    loading: string
    event: string
    events: (count: number) => string
    selectView: string
    week: (weekNumber: number) => string
    resources: string
    goToDate: string
    /** Cursor hint while a drag/resize hovers a rejected position. */
    dropNotAllowed: string
    /** Aria-label suffix on chip segments that continue past the cell. */
    continues: string
    /** Agenda label for the first day of a multi-day event. */
    timeFrom: (time: string) => string
    /** Agenda label for the last day of a multi-day event. */
    timeUntil: (time: string) => string
    /** View-switcher shortcut hint characters, per view. */
    viewShortcuts: Record<CalendarView, string>
    /** Aria-label of the agenda day collapse/expand toggle. */
    toggleDayEvents: (count: number, expanded: boolean) => string
    /** Aria-label of the agenda event details toggle. */
    eventDetails: (title: string) => string
    /** Compact "+N" overflow (agenda summary dot stack). */
    moreCompact: (count: number) => string
    /** Joins a bounded from-to time span. */
    timeRange: (from: string, to: string) => string
  }
  viewNames: {
    month: string
    week: string
    day: string
    days: (count: number) => string
    agenda: string
    resource: string
  }
  /** date-fns format strings, applied with the calendar `locale`. */
  formats: {
    monthTitle: string
    /** Undefined = smart cross-month range via functions.formatTitle. */
    weekTitle?: string
    dayTitle: string
    /** Undefined = smart range label via functions.formatTitle. */
    agendaTitle?: string
    monthDayHeader: string
    /** Narrow variant used by the month view below the compact breakpoint. */
    monthDayHeaderNarrow: string
    timeGridDayHeader: string
    agendaDayHeader: string
    /** Agenda date-gutter day number. */
    agendaDayNumber: string
    /** Agenda date-gutter weekday label. */
    agendaWeekday: string
    /** "+N more" popover day header. */
    moreDayHeader: string
    /** Month cell aria-label date. */
    monthCellAriaLabel: string
    /** Time-grid day column aria-label date. */
    dayAria: string
    /** Undefined = the resource view title falls back to dayTitle. */
    resourceTitle?: string
    timeGutter: string
    /** Sub-hour gutter labels (interval below 60 minutes). */
    timeGutterMinute: string
    eventTime: string
    monthCellDay: string
  }
  functions: {
    formatTitle: (
      view: CalendarView,
      ctx: {
        date: Date
        activeRange: EventCalendarDateRange
        visibleRange: EventCalendarDateRange
        locale?: Locale
      }
    ) => string
    formatEventTime: (
      start: Date,
      end: Date,
      allDay: boolean,
      /** date-fns options (the calendar `locale`); trailing so a 3-arg override still fits. */
      opts?: { locale?: Locale }
    ) => string
    formatDayRange: (
      range: EventCalendarDateRange,
      opts?: { locale?: Locale }
    ) => string
    /** Chip native tooltip text; return undefined to drop the attribute. */
    formatEventLabel?: (title: string, timeLabel: string) => string | undefined
    /** Chip aria-label composition. */
    formatEventAriaLabel?: (
      title: string,
      timeLabel: string,
      continues: boolean
    ) => string
  }
}

const DEFAULT_LABELS: EventCalendarI18nConfig["labels"] = {
  today: "Today",
  previous: "Previous",
  next: "Next",
  addEvent: "Add event",
  allDay: "All day",
  more: (count) => `+${count} more`,
  noEvents: "No events",
  loading: "Loading events",
  event: "event",
  events: (count) => (count === 1 ? "1 event" : `${count} events`),
  selectView: "Select view",
  week: (weekNumber) => `W${weekNumber}`,
  resources: "Resources",
  goToDate: "Go to date",
  dropNotAllowed: "Can't place here",
  continues: "continues",
  timeFrom: (time) => `From ${time}`,
  timeUntil: (time) => `Until ${time}`,
  viewShortcuts: {
    month: "M",
    week: "W",
    day: "D",
    days: "5",
    agenda: "A",
    resource: "G",
  },
  toggleDayEvents: (count) => (count === 1 ? "1 event" : `${count} events`),
  eventDetails: (title) => title,
  moreCompact: (count) => `+${count}`,
  timeRange: (from, to) => `${from} - ${to}`,
}

const DEFAULT_VIEW_NAMES: EventCalendarI18nConfig["viewNames"] = {
  month: "Month",
  week: "Week",
  day: "Day",
  days: (count) => (count === 1 ? "1 day" : `${count} days`),
  agenda: "Agenda",
  resource: "Time Grid",
}

const DEFAULT_FORMATS: EventCalendarI18nConfig["formats"] = {
  monthTitle: "MMMM yyyy",
  weekTitle: undefined,
  dayTitle: "EEEE, MMMM d, yyyy",
  agendaTitle: undefined,
  monthDayHeader: "EEE",
  monthDayHeaderNarrow: "EEEEE",
  timeGridDayHeader: "EEE d",
  agendaDayHeader: "EEEE, MMMM d",
  agendaDayNumber: "d",
  agendaWeekday: "EEE",
  moreDayHeader: "EEEE, MMMM d",
  monthCellAriaLabel: "PPPP",
  dayAria: "PPPP",
  resourceTitle: undefined,
  timeGutter: "h a",
  timeGutterMinute: "h:mm a",
  eventTime: "h:mm a",
  monthCellDay: "d",
}

/**
 * Default formatting functions BOUND to a config's labels/formats, so that
 * `formats` overrides flow into the default renderers (a consumer overriding
 * formats.monthTitle without replacing formatTitle still sees it applied).
 */
function makeDefaultFunctions(
  cfg: Pick<EventCalendarI18nConfig, "labels" | "formats">
): EventCalendarI18nConfig["functions"] {
  return {
    formatTitle: (view, { date, activeRange, locale }) => {
      const opts = { locale }
      if (view === "month") {
        return format(date, cfg.formats.monthTitle, opts)
      }
      if (view === "resource") {
        return format(
          date,
          cfg.formats.resourceTitle ?? cfg.formats.dayTitle,
          opts
        )
      }
      if (view === "day") {
        return format(date, cfg.formats.dayTitle, opts)
      }
      if (view === "week" && cfg.formats.weekTitle) {
        return format(date, cfg.formats.weekTitle, opts)
      }
      if (view === "agenda" && cfg.formats.agendaTitle) {
        return format(date, cfg.formats.agendaTitle, opts)
      }
      // week / days / agenda: smart range label, last day is activeRange.end - 1ms.
      // subMilliseconds keeps the zoned date type (a plain new Date(ms)
      // would flip the label to the machine zone near midnight)
      const rangeEnd = subMilliseconds(activeRange.end, 1)
      const start = activeRange.start
      if (isSameMonth(start, rangeEnd)) {
        return `${format(start, "MMMM d", opts)} - ${format(rangeEnd, "d, yyyy", opts)}`
      }
      if (isSameYear(start, rangeEnd)) {
        return `${format(start, "MMM d", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
      }
      return `${format(start, "MMM d, yyyy", opts)} - ${format(rangeEnd, "MMM d, yyyy", opts)}`
    },
    formatEventTime: (start, end, allDay, opts) => {
      if (allDay) return cfg.labels.allDay
      const fmt = cfg.formats.eventTime
      // Multi-day timed events carry the date on both sides. Compare calendar
      // days off the last rendered instant (end is exclusive, so a 14:00 to
      // midnight event still ends on the start day). Elapsed ms would miss an
      // exactly-24h event and a DST day that only runs 23 hours.
      const lastInstant =
        end.getTime() - 1 >= start.getTime() ? subMilliseconds(end, 1) : start
      if (format(start, "yyyy-MM-dd") !== format(lastInstant, "yyyy-MM-dd")) {
        return `${format(start, `MMM d, ${fmt}`, opts)} - ${format(end, `MMM d, ${fmt}`, opts)}`
      }
      return `${format(start, fmt, opts)} - ${format(end, fmt, opts)}`
    },
    formatDayRange: (range, opts) => {
      // subMilliseconds keeps the zoned date type, same reason as formatTitle
      const rangeEnd = subMilliseconds(range.end, 1)
      return `${format(range.start, "MMM d", opts)} - ${format(rangeEnd, "MMM d", opts)}`
    },
  }
}

const DEFAULT_EVENT_CALENDAR_I18N: EventCalendarI18nConfig = {
  labels: DEFAULT_LABELS,
  viewNames: DEFAULT_VIEW_NAMES,
  formats: DEFAULT_FORMATS,
  functions: makeDefaultFunctions({
    labels: DEFAULT_LABELS,
    formats: DEFAULT_FORMATS,
  }),
}

/**
 * One level deeper than `Partial`, because the merge below is per nested
 * section: overriding a single label must not force a consumer to restate the
 * other 22. Unknown keys are still rejected by the excess property check.
 */
type EventCalendarI18nOverrides = {
  [K in keyof EventCalendarI18nConfig]?: Partial<EventCalendarI18nConfig[K]>
}

/**
 * Shallow merge per nested object, matching the filters.tsx i18n contract:
 * a partial override replaces individual keys, never whole sections. Default
 * functions are re-bound to the MERGED labels/formats; explicit `functions`
 * overrides still win.
 */
function mergeEventCalendarI18n(
  overrides?: EventCalendarI18nOverrides
): EventCalendarI18nConfig {
  if (!overrides) return DEFAULT_EVENT_CALENDAR_I18N
  const labels = { ...DEFAULT_LABELS, ...overrides.labels }
  const viewNames = { ...DEFAULT_VIEW_NAMES, ...overrides.viewNames }
  const formats = { ...DEFAULT_FORMATS, ...overrides.formats }
  return {
    labels,
    viewNames,
    formats,
    functions: {
      ...makeDefaultFunctions({ labels, formats }),
      ...overrides.functions,
    },
  }
}

export { DEFAULT_EVENT_CALENDAR_I18N, mergeEventCalendarI18n }
export type { EventCalendarI18nConfig, EventCalendarI18nOverrides }

src/reui/event-calendar/index.ts

// Примечание: lib.ts/recurrence.ts НЕ реэкспортируются целиком отсюда — это
// внутренняя чистая математика (buildEventIndex, expandRecurrence и т.п.),
// имена которой совпадают с одноимёнными помощниками reui/gantt (общее
// происхождение в апстриме). `export *` из обоих барелей дал бы коллизию в
// публичном API @revueui/ui (тот же приём, что уже применён в reui/kanban
// для useIsOverlay/IsOverlayContextKey). Остальные файлы event-calendar
// импортируют их напрямую через относительный путь ("./lib", "./recurrence").
export * from "./types"
export * from "./context"
export { default as EventCalendar } from "./EventCalendar.vue"
export { default as EventCalendarContent } from "./EventCalendarContent.vue"
export { default as EventCalendarNav } from "./EventCalendarNav.vue"
export { default as EventCalendarEvent } from "./EventCalendarEvent.vue"
export { default as EventCalendarMonthView } from "./EventCalendarMonthView.vue"
export { default as EventCalendarTimeGrid } from "./EventCalendarTimeGrid.vue"
export { default as EventCalendarWeekView } from "./EventCalendarWeekView.vue"
export { default as EventCalendarDayView } from "./EventCalendarDayView.vue"
export { default as EventCalendarDaysView } from "./EventCalendarDaysView.vue"
export { default as EventCalendarAgendaView } from "./EventCalendarAgendaView.vue"
export { default as EventCalendarResourceView } from "./EventCalendarResourceView.vue"

src/reui/event-calendar/lib.ts

// Title: Event Calendar Lib
// Description: Pure, framework-free calendar math: view ranges, zoned day keys, multi-day segmentation, overlap packing, lane packing, and the event index.
// Port of ReUI event-calendar-lib.tsx (MIT) — pure TS/date-fns, ported verbatim.

import { expandRecurrence } from "./recurrence"
import type {
  CalendarEvent,
  CalendarView,
  EventCalendarDateRange,
  EventCalendarOccurrence,
  EventCalendarOffDaysConfig,
  EventCalendarResource,
  EventCalendarSegment,
} from "./types"
import { TZDate } from "@date-fns/tz"
import {
  addDays,
  addMonths,
  addWeeks,
  differenceInCalendarDays,
  differenceInMinutes,
  format,
  startOfDay,
  startOfMonth,
  startOfWeek,
} from "date-fns"

type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6

/** Packing-effective minimum in minutes so tiny events do not stack invisibly. */
const MIN_PACK_SLOT = 30

/** The instant re-expressed in the display time zone (TZDate extends Date). */
function toZoned(date: Date, timeZone: string): TZDate {
  return new TZDate(date.getTime(), timeZone)
}

/** Zoned midnight of the day containing the instant. */
function zonedStartOfDay(date: Date, timeZone: string): TZDate {
  return startOfDay(toZoned(date, timeZone))
}

/** Stable per-day key in the display time zone. */
function getDayKey(date: Date, timeZone: string): string {
  return format(toZoned(date, timeZone), "yyyy-MM-dd")
}

/** Day length in minutes; 1380/1500 on DST transition days - never assume 1440. */
function getDayTotalMinutes(dayStart: Date, timeZone: string): number {
  const next = zonedStartOfDay(
    addDays(toZoned(dayStart, timeZone), 1),
    timeZone
  )
  return differenceInMinutes(next, dayStart)
}

function snapMinutes(minutes: number, snap: number): number {
  return Math.round(minutes / snap) * snap
}

interface ViewRangeOptions {
  timeZone: string
  weekStartsOn: WeekStartsOn
  dayCount: number
  agendaDayCount: number
  fixedWeeks: boolean
}

interface ViewDateRanges {
  visibleRange: EventCalendarDateRange
  activeRange: EventCalendarDateRange
}

function getViewDateRange(
  view: CalendarView,
  date: Date,
  opts: ViewRangeOptions
): ViewDateRanges {
  const { timeZone, weekStartsOn, dayCount, agendaDayCount, fixedWeeks } = opts
  const zoned = toZoned(date, timeZone)

  if (view === "month") {
    const activeStart = startOfMonth(zoned)
    const activeEnd = startOfMonth(addMonths(zoned, 1))
    const visibleStart = startOfWeek(activeStart, { weekStartsOn })
    let visibleEnd: Date
    if (fixedWeeks) {
      visibleEnd = addDays(visibleStart, 42)
    } else {
      visibleEnd = startOfWeek(addDays(activeEnd, -1), { weekStartsOn })
      visibleEnd = addWeeks(visibleEnd, 1)
    }
    return {
      activeRange: { start: activeStart, end: activeEnd },
      visibleRange: { start: visibleStart, end: visibleEnd },
    }
  }

  if (view === "week") {
    const start = startOfWeek(zoned, { weekStartsOn })
    const range = { start, end: addWeeks(start, 1) }
    return { activeRange: range, visibleRange: range }
  }

  if (view === "day" || view === "resource") {
    const start = startOfDay(zoned)
    const range = { start, end: addDays(start, 1) }
    return { activeRange: range, visibleRange: range }
  }

  if (view === "days") {
    const start = startOfDay(zoned)
    const range = { start, end: addDays(start, Math.max(1, dayCount)) }
    return { activeRange: range, visibleRange: range }
  }

  // agenda
  const start = startOfDay(zoned)
  const range = { start, end: addDays(start, Math.max(1, agendaDayCount)) }
  return { activeRange: range, visibleRange: range }
}

/** Day of month of the last day of the month containing the zoned date. */
function lastDayOfZonedMonth(date: Date): number {
  return addDays(startOfMonth(addMonths(date, 1)), -1).getDate()
}

/** The anchor date stepped one period forward or backward for the view. */
function stepDate(
  view: CalendarView,
  date: Date,
  direction: 1 | -1,
  opts: Pick<ViewRangeOptions, "timeZone" | "dayCount" | "agendaDayCount">
): Date {
  const zoned = toZoned(date, opts.timeZone)
  if (view === "month") {
    const stepped = addMonths(zoned, direction)
    // addMonths clamps the day down into a shorter month and never restores
    // it, so next-then-prev from the 31st would leave the anchor on the 28th.
    // Sticking a month end to the target month's end keeps stepping
    // invertible, which matters because the anchor is what day and week view
    // open on after a month navigation.
    if (zoned.getDate() !== lastDayOfZonedMonth(zoned)) return stepped
    return addDays(stepped, lastDayOfZonedMonth(stepped) - stepped.getDate())
  }
  if (view === "week") return addWeeks(zoned, direction)
  if (view === "day" || view === "resource") return addDays(zoned, direction)
  if (view === "days")
    return addDays(zoned, direction * Math.max(1, opts.dayCount))
  return addDays(zoned, direction * Math.max(1, opts.agendaDayCount))
}

function rangesIntersect(
  a: EventCalendarDateRange,
  b: EventCalendarDateRange
): boolean {
  return a.start < b.end && a.end > b.start
}

function eventsOverlap(
  a: { start: Date; end: Date },
  b: { start: Date; end: Date }
): boolean {
  return a.start < b.end && a.end > b.start
}

/**
 * The one canonical multi-day segmentation. Splits an occurrence into per-day
 * segments clamped to the range. Rules (unit-tested in M1): exclusive end - an
 * event ending exactly at zoned midnight emits NO segment for that day;
 * zero-duration events emit one min-height segment; allDay occurrences walk
 * the same absolute instants as timed ones and only drop startMin/endMin, so
 * their bounds have to already BE display-zone midnights (see
 * CalendarEvent.allDay) or the bar paints on the wrong days.
 */
function segmentOccurrence<TData>(
  occurrence: EventCalendarOccurrence<TData>,
  range: EventCalendarDateRange,
  timeZone: string
): EventCalendarSegment<TData>[] {
  const occStart = occurrence.start
  const occEnd = occurrence.end
  const isZeroLength = occEnd.getTime() === occStart.getTime()

  const clampStart = occStart > range.start ? occStart : range.start
  const clampEnd = occEnd < range.end ? occEnd : range.end
  if (clampEnd < clampStart) return []
  if (clampEnd.getTime() === clampStart.getTime() && !isZeroLength) return []

  const segments: EventCalendarSegment<TData>[] = []
  let cursor = zonedStartOfDay(clampStart, timeZone)

  while (cursor < clampEnd || (isZeroLength && segments.length === 0)) {
    const next = zonedStartOfDay(
      addDays(toZoned(cursor, timeZone), 1),
      timeZone
    )
    const segStart = clampStart > cursor ? clampStart : cursor
    const segEnd = clampEnd < next ? clampEnd : next

    const emptySeg = segEnd.getTime() <= segStart.getTime()
    if (!emptySeg || isZeroLength) {
      const isStart = segStart.getTime() === occStart.getTime()
      const isEnd = segEnd.getTime() === occEnd.getTime()
      segments.push({
        occurrence,
        day: cursor,
        isStart,
        isEnd,
        continuesBefore: !isStart,
        continuesAfter: !isEnd,
        startMin: occurrence.allDay
          ? undefined
          : differenceInMinutes(segStart, cursor),
        endMin: occurrence.allDay
          ? undefined
          : Math.max(
              differenceInMinutes(segEnd, cursor),
              differenceInMinutes(segStart, cursor)
            ),
      })
    }
    if (isZeroLength) break
    cursor = next
  }

  return segments
}

/** True when the occurrence should render as a bar (all-day row / month lanes). */
function isBarOccurrence(
  occurrence: EventCalendarOccurrence,
  timeZone?: string
): boolean {
  return occurrence.allDay || spansMultipleDays(occurrence, timeZone)
}

function spansMultipleDays(
  occ: { start: Date; end: Date },
  timeZone?: string
): boolean {
  // An event ending exactly at the next midnight is still single-day
  // (exclusive end), so compare against a strictly-later instant. The
  // yardstick is the length of the day the event starts on, never a flat 24h:
  // a fall-back day is 25h long, and a 00:00-to-00:00 shift on it is still one
  // calendar day that belongs in the hour track, not in the all-day row.
  // Without a display zone the dates answer in their own frame (TZDate) or in
  // the host zone.
  const dayStart = startOfDay(
    timeZone ? toZoned(occ.start, timeZone) : occ.start
  )
  const nextDayStart = startOfDay(addDays(dayStart, 1))
  return (
    occ.end.getTime() - occ.start.getTime() >
    nextDayStart.getTime() - dayStart.getTime()
  )
}

interface PackedPosition {
  column: number
  columnCount: number
  columnSpan: number
}

/**
 * Google-style overlap packing for one day's timed segments.
 * Mutates column/columnCount/columnSpan on the segments, in place.
 * z resolution happens at render: event.zIndex verbatim, else 10 + column.
 */
function packTimedSegments<TData>(
  segments: EventCalendarSegment<TData>[]
): void {
  if (segments.length === 0) return

  type Working = {
    seg: EventCalendarSegment<TData>
    startMin: number
    effEnd: number
  }

  const items: Working[] = segments
    .map((seg) => {
      const startMin = seg.startMin ?? 0
      const endMin = seg.endMin ?? startMin
      return {
        seg,
        startMin,
        effEnd: Math.max(endMin, startMin + MIN_PACK_SLOT),
      }
    })
    .sort(
      (a, b) =>
        a.startMin - b.startMin ||
        b.effEnd - b.startMin - (a.effEnd - a.startMin) ||
        a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
    )

  // Sweep into connected clusters
  const clusters: Working[][] = []
  let current: Working[] = []
  let clusterEnd = -Infinity
  for (const item of items) {
    if (item.startMin >= clusterEnd) {
      current = []
      clusters.push(current)
      clusterEnd = -Infinity
    }
    current.push(item)
    clusterEnd = Math.max(clusterEnd, item.effEnd)
  }

  for (const cluster of clusters) {
    // Greedy column assignment
    const colEnds: number[] = []
    const byColumn = new Map<number, Working[]>()
    for (const item of cluster) {
      let col = colEnds.findIndex((end) => end <= item.startMin)
      if (col === -1) {
        col = colEnds.length
        colEnds.push(0)
      }
      colEnds[col] = item.effEnd
      item.seg.column = col
      const bucket = byColumn.get(col) ?? []
      bucket.push(item)
      byColumn.set(col, bucket)
    }
    const columnCount = colEnds.length

    // Partial-overlap expansion: widen rightward into free columns
    for (const item of cluster) {
      let span = 1
      const col = item.seg.column ?? 0
      while (col + span < columnCount) {
        const occupants = byColumn.get(col + span) ?? []
        const blocked = occupants.some(
          (o) => o.startMin < item.effEnd && o.effEnd > item.startMin
        )
        if (blocked) break
        span++
      }
      item.seg.columnCount = columnCount
      item.seg.columnSpan = span
    }
  }
}

/**
 * Greedy lane packing for bar segments within one week row (7 columns).
 * Mutates lane/rowIndex/colStart/colSpan on the segments, in place.
 */
/**
 * Build the laned month-row bars for one week: consecutive-day segments of
 * the same occurrence merge into ONE bar (colStart -> colSpan) stacked into
 * lanes. Returns NEW segment objects - the shared per-day segments (also
 * rendered by the all-day rows and day cells) must stay pristine: mutating
 * their isEnd/continues flags gave the first-day chip a whole-bar shape and
 * a bogus end resize handle in the week all-day row, where dragging it
 * collapsed the event to a single day.
 */
function packWeekRowLanes<TData>(
  segments: EventCalendarSegment<TData>[],
  rowIndex: number,
  rowStart: Date,
  timeZone: string
): EventCalendarSegment<TData>[] {
  type Bar = {
    seg: EventCalendarSegment<TData>
    colStart: number
    colSpan: number
    isStart: boolean
    isEnd: boolean
    lane: number
  }

  const bars: Bar[] = segments.map((seg) => {
    const dayIndex = Math.round(
      (zonedStartOfDay(seg.day, timeZone).getTime() -
        zonedStartOfDay(rowStart, timeZone).getTime()) /
        (24 * 60 * 60 * 1000)
    )
    return {
      seg,
      colStart: Math.max(0, Math.min(6, dayIndex)),
      colSpan: 1,
      isStart: seg.isStart,
      isEnd: seg.isEnd,
      lane: 0,
    }
  })

  // Merge consecutive-day segments of the same occurrence into one bar per row
  const merged = new Map<string, Bar>()
  for (const bar of bars) {
    const key = bar.seg.occurrence.key
    const existing = merged.get(key)
    if (existing) {
      const start = Math.min(existing.colStart, bar.colStart)
      const end = Math.max(
        existing.colStart + existing.colSpan,
        bar.colStart + bar.colSpan
      )
      existing.colStart = start
      existing.colSpan = end - start
      existing.isStart = existing.isStart || bar.isStart
      existing.isEnd = existing.isEnd || bar.isEnd
    } else {
      merged.set(key, bar)
    }
  }

  const rowBars = Array.from(merged.values()).sort(
    (a, b) =>
      a.colStart - b.colStart ||
      b.colSpan - a.colSpan ||
      a.seg.occurrence.key.localeCompare(b.seg.occurrence.key)
  )

  const lanes: boolean[][] = []
  for (const bar of rowBars) {
    let lane = 0
    for (;;) {
      const row = (lanes[lane] ??= new Array(7).fill(false))
      let free = true
      for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
        if (row[c]) {
          free = false
          break
        }
      }
      if (free) break
      lane++
    }
    const row = lanes[lane]!
    for (let c = bar.colStart; c < bar.colStart + bar.colSpan; c++) {
      row[c] = true
    }
    bar.lane = lane
  }

  return rowBars.map((bar) => ({
    ...bar.seg,
    isStart: bar.isStart,
    isEnd: bar.isEnd,
    continuesBefore: !bar.isStart,
    continuesAfter: !bar.isEnd,
    lane: bar.lane,
    rowIndex,
    colStart: bar.colStart,
    colSpan: bar.colSpan,
  }))
}

interface EventCalendarDayBucket<TData = unknown> {
  allDay: EventCalendarSegment<TData>[]
  timed: EventCalendarSegment<TData>[]
}

interface EventCalendarWeekRow<TData = unknown> {
  rowIndex: number
  rowStart: Date
  /** Laned bar segments (one per occurrence per row). */
  bars: EventCalendarSegment<TData>[]
}

interface EventCalendarIndex<TData = unknown> {
  occurrences: EventCalendarOccurrence<TData>[]
  byDay: Map<string, EventCalendarDayBucket<TData>>
  weekRows: EventCalendarWeekRow<TData>[]
}

interface BuildIndexOptions<TData> {
  timeZone: string
  weekStartsOn: WeekStartsOn
  eventOrder?: (
    a: EventCalendarOccurrence<TData>,
    b: EventCalendarOccurrence<TData>
  ) => number
  getOccurrences?: (
    event: CalendarEvent<TData>,
    range: EventCalendarDateRange,
    ctx: { timeZone: string }
  ) => Array<{ start: Date; end: Date }> | null
}

function defaultEventOrder(
  a: EventCalendarOccurrence,
  b: EventCalendarOccurrence
): number {
  return (
    a.start.getTime() - b.start.getTime() ||
    b.end.getTime() -
      b.start.getTime() -
      (a.end.getTime() - a.start.getTime()) ||
    a.key.localeCompare(b.key)
  )
}

function buildEventIndex<TData>(
  events: CalendarEvent<TData>[],
  visibleRange: EventCalendarDateRange,
  opts: BuildIndexOptions<TData>
): EventCalendarIndex<TData> {
  const { timeZone, weekStartsOn } = opts
  const order = opts.eventOrder ?? defaultEventOrder

  // RECURRENCE-ID override replacement: an event carrying recurringEventId +
  // originalStart is an edited single occurrence of that series. The parent's
  // expansion drops the replaced instant; the override renders as its own
  // occurrence through the normal path below.
  const overrideTimes = new Map<string, Set<number>>()
  for (const event of events) {
    if (!event.recurringEventId || !event.originalStart) continue
    let times = overrideTimes.get(event.recurringEventId)
    if (!times) overrideTimes.set(event.recurringEventId, (times = new Set()))
    times.add(event.originalStart.getTime())
  }

  const occurrences: EventCalendarOccurrence<TData>[] = []
  for (const event of events) {
    const replaced = overrideTimes.get(event.id)
    const custom = opts.getOccurrences?.(event, visibleRange, { timeZone })
    if (custom) {
      custom.forEach((occ, i) => {
        if (replaced?.has(occ.start.getTime())) return
        if (!rangesIntersect({ start: occ.start, end: occ.end }, visibleRange))
          return
        occurrences.push({
          key: `${event.id}::${occ.start.toISOString()}`,
          eventId: event.id,
          event,
          start: occ.start,
          end: occ.end,
          allDay: event.allDay ?? false,
          isRecurring: true,
          recurrenceIndex: i,
        })
      })
      continue
    }
    const expanded = expandRecurrence(event, visibleRange, { timeZone })
    occurrences.push(
      ...(replaced
        ? expanded.filter((occ) => !replaced.has(occ.start.getTime()))
        : expanded)
    )
  }
  occurrences.sort(order)

  const byDay = new Map<string, EventCalendarDayBucket<TData>>()
  const barSegmentsByRow = new Map<number, EventCalendarSegment<TData>[]>()
  const firstRowStart = startOfWeek(toZoned(visibleRange.start, timeZone), {
    weekStartsOn,
  })

  for (const occurrence of occurrences) {
    const segments = segmentOccurrence(occurrence, visibleRange, timeZone)
    const bar = isBarOccurrence(occurrence, timeZone)
    for (const seg of segments) {
      const key = getDayKey(seg.day, timeZone)
      let bucket = byDay.get(key)
      if (!bucket) {
        bucket = { allDay: [], timed: [] }
        byDay.set(key, bucket)
      }
      if (bar) {
        bucket.allDay.push(seg)
        // calendar-day math, not a fixed 168h divisor: DST transition weeks
        // are 167/169h long and the fixed divisor mis-buckets every later
        // Sunday one row early (which then clamps into the wrong column)
        const rowIndex = Math.floor(
          differenceInCalendarDays(toZoned(seg.day, timeZone), firstRowStart) /
            7
        )
        const rowBucket = barSegmentsByRow.get(rowIndex) ?? []
        rowBucket.push(seg)
        barSegmentsByRow.set(rowIndex, rowBucket)
      } else {
        bucket.timed.push(seg)
      }
    }
  }

  for (const bucket of byDay.values()) {
    packTimedSegments(bucket.timed)
  }

  const weekRows: EventCalendarWeekRow<TData>[] = []
  for (const [rowIndex, segs] of barSegmentsByRow) {
    const rowStart = addWeeks(firstRowStart, rowIndex)
    weekRows.push({
      rowIndex,
      rowStart,
      bars: packWeekRowLanes(segs, rowIndex, rowStart, timeZone),
    })
  }
  weekRows.sort((a, b) => a.rowIndex - b.rowIndex)

  return { occurrences, byDay, weekRows }
}

/** Cache key for index memoization; cheap string compare. */
function getRangeKey(range: EventCalendarDateRange): string {
  return `${range.start.getTime()}-${range.end.getTime()}`
}

/** Depth-first flatten of the resource tree (parents included). */
function flattenResources(
  resources: EventCalendarResource[],
  depth = 0
): Array<{ resource: EventCalendarResource; depth: number }> {
  const rows: Array<{ resource: EventCalendarResource; depth: number }> = []
  for (const resource of resources) {
    rows.push({ resource, depth })
    if (resource.children?.length) {
      rows.push(...flattenResources(resource.children, depth + 1))
    }
  }
  return rows
}

const DEFAULT_WEEKEND_DAYS = [0, 6]

/**
 * Resolves whether a day is an off day (non-working) in the display zone.
 * Callers pass the calendar's own weekendDays so the shading cannot contradict
 * the weekend the rest of the calendar renders; an explicit offDays.weekendDays
 * still wins over it.
 */
function resolveOffDay(
  day: Date,
  timeZone: string,
  config: boolean | EventCalendarOffDaysConfig | undefined,
  defaultWeekendDays?: number[]
): boolean {
  if (!config) return false
  const resolved: EventCalendarOffDaysConfig = config === true ? {} : config
  const weekendDays =
    resolved.weekendDays ?? defaultWeekendDays ?? DEFAULT_WEEKEND_DAYS
  const zoned = toZoned(day, timeZone)
  if (weekendDays.includes(zoned.getDay())) return true
  if (resolved.dates?.length) {
    const key = getDayKey(day, timeZone)
    if (resolved.dates.some((date) => getDayKey(date, timeZone) === key)) {
      return true
    }
  }
  return resolved.isOffDay?.(day) ?? false
}

export {
  buildEventIndex,
  defaultEventOrder,
  eventsOverlap,
  flattenResources,
  getDayKey,
  getDayTotalMinutes,
  getRangeKey,
  getViewDateRange,
  isBarOccurrence,
  MIN_PACK_SLOT,
  packTimedSegments,
  packWeekRowLanes,
  rangesIntersect,
  resolveOffDay,
  segmentOccurrence,
  snapMinutes,
  spansMultipleDays,
  stepDate,
  toZoned,
  zonedStartOfDay,
}
export type {
  BuildIndexOptions,
  EventCalendarDayBucket,
  EventCalendarIndex,
  EventCalendarWeekRow,
  PackedPosition,
  ViewDateRanges,
  ViewRangeOptions,
  WeekStartsOn,
}

src/reui/event-calendar/recurrence.ts

// Title: Event Calendar Recurrence
// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.
// Port of ReUI event-calendar-recurrence.tsx (MIT) — pure TS/date-fns, ported verbatim.

import type {
  CalendarEvent,
  EventCalendarDateRange,
  EventCalendarOccurrence,
  EventCalendarRecurrenceRule,
  EventCalendarWeekday,
} from "./types"
import { TZDate } from "@date-fns/tz"
import { addDays, addMonths, addWeeks, addYears } from "date-fns"

/** Guard: max window-intersecting occurrences per event per expansion. */
const MAX_OCCURRENCES = 1000

/** Runaway guard: absolute cap on period iterations regardless of visibility. */
const MAX_ITERATIONS = 10000

/** Gregorian mean period lengths for the O(1) fast-forward approximation. */
const PERIOD_MS: Record<EventCalendarRecurrenceRule["freq"], number> = {
  daily: 86400000,
  weekly: 604800000,
  monthly: 2629746000, // 365.2425 / 12 days
  yearly: 31556952000, // 365.2425 days
}

const WEEKDAYS: EventCalendarWeekday[] = [
  "SU",
  "MO",
  "TU",
  "WE",
  "TH",
  "FR",
  "SA",
]

class EventCalendarRecurrenceError extends Error {
  constructor(part: string) {
    super(
      `Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`
    )
    this.name = "EventCalendarRecurrenceError"
  }
}

/**
 * Parses a raw RRULE line (with or without the "RRULE:" prefix) into the
 * structured subset. Pass the display time zone so Z-less UNTIL values are
 * interpreted as wall time in that zone rather than the machine zone.
 */
function parseRRuleString(
  input: string,
  timeZone?: string
): EventCalendarRecurrenceRule {
  const body = input.trim().replace(/^RRULE:/i, "")
  const rule: Partial<EventCalendarRecurrenceRule> = {}

  for (const pair of body.split(";")) {
    if (!pair) continue
    const [rawKey, rawValue] = pair.split("=")
    const key = rawKey?.toUpperCase()
    const value = rawValue ?? ""

    switch (key) {
      case "FREQ": {
        const freq = value.toLowerCase()
        if (
          freq !== "daily" &&
          freq !== "weekly" &&
          freq !== "monthly" &&
          freq !== "yearly"
        ) {
          throw new EventCalendarRecurrenceError(`FREQ=${value}`)
        }
        rule.freq = freq
        break
      }
      case "INTERVAL":
        rule.interval = Math.max(1, parseInt(value, 10) || 1)
        break
      case "COUNT":
        rule.count = Math.max(1, parseInt(value, 10) || 1)
        break
      case "UNTIL":
        rule.until = parseRRuleDate(value, timeZone)
        break
      case "BYDAY":
        rule.byWeekday = value.split(",").map((token) => {
          // RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is
          // already folded above - rejecting "mo" here would be inconsistent
          const match = /^(-?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(
            token.trim().toUpperCase()
          )
          if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)
          const day = match[2] as EventCalendarWeekday
          return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day
        })
        break
      case "BYMONTHDAY":
        rule.byMonthDay = value.split(",").map((v) => {
          const day = parseInt(v, 10)
          // NaN would survive parsing, match no day in any month and leave the
          // event permanently invisible with no error anywhere
          if (Number.isNaN(day)) {
            throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)
          }
          return day
        })
        break
      case "BYMONTH":
        rule.byMonth = value.split(",").map((v) => parseInt(v, 10))
        break
      case "WKST": {
        const day = value.trim().toUpperCase() as EventCalendarWeekday
        if (!WEEKDAYS.includes(day)) {
          throw new EventCalendarRecurrenceError(`WKST=${value}`)
        }
        rule.weekStart = day
        break
      }
      default:
        throw new EventCalendarRecurrenceError(key ?? pair)
    }
  }

  if (!rule.freq) throw new EventCalendarRecurrenceError("missing FREQ")
  return rule as EventCalendarRecurrenceRule
}

function parseRRuleDate(value: string, timeZone?: string): Date {
  // RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z
  // designators are case-insensitive too (RFC 5545 3.1), so fold before matching.
  const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(
    value.trim().toUpperCase()
  )
  if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
  const [, y0, m0, d0, hh = "23", mm = "59", ss = "59", z] = match
  // y/m/d are always captured (required groups in the regex above); TS can't
  // see that, so assert non-null rather than widen the type everywhere below.
  const y = y0!
  const m = m0!
  const d = d0!
  // Z-less values (including date-only ones, which mean end of that day
  // inclusive) are wall time in the display zone, not the machine zone.
  const date = z
    ? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)
    : timeZone
      ? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())
      : new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)
  if (Number.isNaN(date.getTime())) {
    throw new EventCalendarRecurrenceError(`UNTIL=${value}`)
  }
  return date
}

/** Serializes the structured subset back to an RRULE line (without prefix). */
function formatRRuleString(rule: EventCalendarRecurrenceRule): string {
  const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]
  if (rule.interval && rule.interval > 1)
    parts.push(`INTERVAL=${rule.interval}`)
  if (rule.count) parts.push(`COUNT=${rule.count}`)
  if (rule.until) {
    const u = rule.until
    const pad = (n: number) => String(n).padStart(2, "0")
    parts.push(
      `UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`
    )
  }
  if (rule.byWeekday?.length) {
    parts.push(
      `BYDAY=${rule.byWeekday
        .map((d) => (typeof d === "string" ? d : `${d.ordinal}${d.day}`))
        .join(",")}`
    )
  }
  if (rule.byMonthDay?.length)
    parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`)
  if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(",")}`)
  if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)
  return parts.join(";")
}

function resolveRule(
  recurrence: EventCalendarRecurrenceRule | string,
  timeZone?: string
): EventCalendarRecurrenceRule {
  return typeof recurrence === "string"
    ? parseRRuleString(recurrence, timeZone)
    : recurrence
}

/**
 * Expands one event into its occurrences intersecting the range.
 * Non-recurring events yield at most one occurrence. Recurrence iteration is
 * wall-time based in the display zone (DST-safe day/week/month steps), and a
 * span that is a whole number of local days keeps that day count across a DST
 * transition (timed spans keep their absolute length instead).
 *
 * exDates remove exactly-matching instants (after COUNT numbering,
 * Google-style: an exception still consumes its COUNT slot); rDates add extra
 * instants with the same duration. RECURRENCE-ID override replacement lives
 * in buildEventIndex, where the override event and its parent series meet.
 */
function expandRecurrence<TData>(
  event: CalendarEvent<TData>,
  range: EventCalendarDateRange,
  ctx: { timeZone: string }
): EventCalendarOccurrence<TData>[] {
  const allDay = event.allDay ?? false

  if (!event.recurrence) {
    // The exclusive `end > start` test is right for anything with duration,
    // but it also drops a zero-length milestone pinned to the first visible
    // instant - which reads as an event that randomly disappears until you
    // page one period back. A point occurrence only has to be inside.
    const isPoint = event.end.getTime() === event.start.getTime()
    if (
      event.start < range.end &&
      (event.end > range.start || (isPoint && event.start >= range.start))
    ) {
      return [
        {
          key: `${event.id}::${event.start.toISOString()}`,
          eventId: event.id,
          event,
          start: event.start,
          end: event.end,
          allDay,
          isRecurring: false,
        },
      ]
    }
    return []
  }

  const rule = resolveRule(event.recurrence, ctx.timeZone)
  const interval = Math.max(1, rule.interval ?? 1)
  const durationMs = event.end.getTime() - event.start.getTime()
  const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)
  // A span of whole local days is wall time, not an absolute delta: a 3 day
  // all-day bar crossing spring forward would otherwise end at 01:00 and
  // occupy a fourth day in the month grid. Timed spans stay absolute so a two
  // hour meeting is still two hours.
  const daySpan = Math.round(durationMs / 86400000)
  const wallDaySpan =
    daySpan > 0 &&
    addDays(zonedStart, daySpan).getTime() === event.end.getTime()
      ? daySpan
      : null
  const endFor = (start: Date): Date =>
    wallDaySpan === null
      ? new Date(start.getTime() + durationMs)
      : new Date(
          addDays(
            new TZDate(start.getTime(), ctx.timeZone),
            wallDaySpan
          ).getTime()
        )
  // Excluded instants matched exactly; filtering happens at push time so an
  // exception still consumes its COUNT slot (Google-style numbering).
  const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))

  const weeklyDays: number[] | null =
    rule.freq === "weekly" && rule.byWeekday?.length
      ? [
          ...new Set(
            rule.byWeekday.map((d) => {
              if (typeof d !== "string") {
                throw new EventCalendarRecurrenceError(
                  "BYDAY ordinal outside monthly/yearly"
                )
              }
              return WEEKDAYS.indexOf(d)
            })
          ),
        ]
      : null

  // BYDAY resolved inside a month: monthly, and yearly within each BYMONTH
  // (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not "the anchor's day")
  const monthlyByDay =
    (rule.freq === "monthly" || rule.freq === "yearly") &&
    rule.byWeekday?.length
      ? rule.byWeekday
      : null

  // RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides
  // which selected days share a period, so an ignored WKST puts half of every
  // biweekly series a week off. Default MO, per the RFC.
  const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? "MO")
  /** Days from the WKST-aligned week start to `day` (0-6). */
  const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7

  // yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month
  const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []
  const yearlyMonths: number[] =
    validByMonth.length > 0
      ? [...new Set(validByMonth)].sort((a, b) => a - b)
      : [zonedStart.getMonth() + 1]

  // month-shaped BY* parts make the per-period occurrence count variable
  const hasMonthDayParts =
    (rule.freq === "monthly" &&
      Boolean(rule.byMonthDay?.length || monthlyByDay)) ||
    (rule.freq === "yearly" &&
      Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))

  const daysInMonth = (year: number, month: number) =>
    new Date(year, month + 1, 0).getDate()

  // RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the
  // set instead of reshaping it. Dropping them silently expanded the series as
  // if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering
  // here rather than at push time keeps COUNT numbering RFC-correct: a
  // candidate the filter removes is not an occurrence and consumes no slot.
  const limitByMonth =
    rule.freq !== "yearly" && validByMonth.length > 0 ? validByMonth : null
  const limitByMonthDay =
    rule.freq === "daily" && rule.byMonthDay?.length ? rule.byMonthDay : null
  const limitByWeekday =
    rule.freq === "daily" && rule.byWeekday?.length
      ? rule.byWeekday.map((d) =>
          WEEKDAYS.indexOf(typeof d === "string" ? d : d.day)
        )
      : null
  const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)

  const passesLimits = (candidate: TZDate): boolean => {
    if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {
      return false
    }
    if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {
      return false
    }
    if (limitByMonthDay) {
      const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())
      const day = candidate.getDate()
      // negative BYMONTHDAY counts back from month end, as in monthDays
      if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {
        return false
      }
    }
    return true
  }

  /** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */
  const zonedDate = (year: number, month: number, day: number) =>
    new TZDate(
      year,
      month,
      day,
      zonedStart.getHours(),
      zonedStart.getMinutes(),
      zonedStart.getSeconds(),
      zonedStart.getMilliseconds(),
      ctx.timeZone
    )

  /** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */
  const monthDays = (year: number, month: number): number[] => {
    const total = daysInMonth(year, month)
    let days: number[] | null = null
    if (rule.byMonthDay?.length) {
      // negative BYMONTHDAY counts back from month end; nonexistent days skip
      days = rule.byMonthDay
        .map((n) => (n < 0 ? total + 1 + n : n))
        .filter((n) => n >= 1 && n <= total)
    }
    if (monthlyByDay) {
      const byDayMatches: number[] = []
      for (const entry of monthlyByDay) {
        const day = typeof entry === "string" ? entry : entry.day
        const ordinal = typeof entry === "string" ? 0 : entry.ordinal
        const weekday = WEEKDAYS.indexOf(day)
        const matches: number[] = []
        for (let d = 1; d <= total; d++) {
          if (new Date(year, month, d).getDay() === weekday) matches.push(d)
        }
        if (ordinal === 0) {
          byDayMatches.push(...matches) // plain BYDAY: every matching weekday
        } else {
          // 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip
          const pick =
            ordinal > 0
              ? matches[ordinal - 1]
              : matches[matches.length + ordinal]
          if (pick !== undefined) byDayMatches.push(pick)
        }
      }
      days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches
    }
    if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end
    return [...new Set(days)].sort((a, b) => a - b)
  }

  /** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */
  const periodCandidates = (period: number): TZDate[] => {
    if (rule.freq === "daily") return [addDays(zonedStart, period * interval)]
    if (rule.freq === "weekly") {
      const base = addWeeks(zonedStart, period * interval)
      if (!weeklyDays) return [base]
      const week: TZDate[] = []
      // walk the WKST-aligned week so candidates stay chronological
      const baseOffset = fromWeekStart(base.getDay())
      for (let offset = 0; offset < 7; offset++) {
        const candidate = addDays(base, offset - baseOffset)
        if (!weeklyDays.includes(candidate.getDay())) continue
        // days of the DTSTART week before DTSTART are not part of the series
        if (candidate.getTime() < zonedStart.getTime()) continue
        week.push(candidate)
      }
      return week
    }
    if (rule.freq === "monthly") {
      const anchor = addMonths(zonedStart, period * interval)
      const year = anchor.getFullYear()
      const month = anchor.getMonth()
      return monthDays(year, month)
        .map((day) => zonedDate(year, month, day))
        .filter((c) => c.getTime() >= zonedStart.getTime())
    }
    // yearly
    const year = addYears(zonedStart, period * interval).getFullYear()
    const dates: TZDate[] = []
    for (const month of yearlyMonths) {
      for (const day of monthDays(year, month - 1)) {
        dates.push(zonedDate(year, month - 1, day))
      }
    }
    return dates.filter((c) => c.getTime() >= zonedStart.getTime())
  }

  /** Candidates of one period with the LIMIT filters applied. */
  const candidatesFor = (period: number): TZDate[] =>
    hasLimits
      ? periodCandidates(period).filter(passesLimits)
      : periodCandidates(period)

  /** Earliest/latest instant a period can produce - loop bounds without expanding it. */
  const periodEdge = (period: number, edge: "first" | "last"): TZDate => {
    if (rule.freq === "daily") return addDays(zonedStart, period * interval)
    if (rule.freq === "weekly") {
      const base = addWeeks(zonedStart, period * interval)
      if (!weeklyDays) return base
      return addDays(
        base,
        (edge === "first" ? 0 : 6) - fromWeekStart(base.getDay())
      )
    }
    if (rule.freq === "monthly") {
      const anchor = addMonths(zonedStart, period * interval)
      const year = anchor.getFullYear()
      const month = anchor.getMonth()
      return zonedDate(
        year,
        month,
        edge === "first" ? 1 : daysInMonth(year, month)
      )
    }
    const year = addYears(zonedStart, period * interval).getFullYear()
    const month =
      // yearlyMonths always has >= 1 entry (empty BYMONTH falls back to the
      // anchor's month above); TS can't see that through the index access.
      (edge === "first"
        ? yearlyMonths[0]!
        : yearlyMonths[yearlyMonths.length - 1]!) - 1
    return zonedDate(
      year,
      month,
      edge === "first" ? 1 : daysInMonth(year, month)
    )
  }

  // O(1) fast-forward: land a couple of periods before the window instead of
  // iterating from DTSTART, so years-old series still reach the visible range.
  const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()
  const stepMs = PERIOD_MS[rule.freq] * interval
  let startPeriod =
    aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0
  // mean-length drift is bounded well under one period - refine forward
  while (
    periodEdge(startPeriod, "last").getTime() + durationMs <=
    range.start.getTime()
  ) {
    startPeriod++
  }

  // series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact
  let index = 0
  if (startPeriod > 0) {
    if (hasMonthDayParts || hasLimits) {
      // per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT
      // filter that empties a whole period) - sum them
      for (let period = 0; period < startPeriod; period++) {
        index += candidatesFor(period).length
      }
    } else if (weeklyDays) {
      // week 0 only counts selected weekdays at/after DTSTART's, inside the
      // WKST-aligned week that holds it
      const anchorOffset = fromWeekStart(zonedStart.getDay())
      const firstWeek = weeklyDays.filter(
        (d) => fromWeekStart(d) >= anchorOffset
      ).length
      index = firstWeek + (startPeriod - 1) * weeklyDays.length
    } else {
      index = startPeriod // one occurrence per period
    }
  }

  const occurrences: EventCalendarOccurrence<TData>[] = []

  const pushIfVisible = (rawStart: Date) => {
    // normalize to a plain instant so consumers never receive zone-carrying
    // TZDate instances (mixed-zone formatting bugs)
    const start = new Date(rawStart.getTime())
    if (exTimes.has(start.getTime())) return
    const end = endFor(start)
    if (start < range.end && end > range.start) {
      occurrences.push({
        key: `${event.id}::${start.toISOString()}`,
        eventId: event.id,
        event,
        start,
        end,
        allDay,
        isRecurring: true,
        recurrenceIndex: index,
      })
    }
  }

  let iterations = 0
  let period = startPeriod
  while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {
    iterations++
    if (rule.count !== undefined && index >= rule.count) break
    // whole-period bounds: never break on a mid-period weekday/day-of-month,
    // so earlier candidates of the final period are still emitted
    const earliest = periodEdge(period, "first")
    if (earliest.getTime() >= range.end.getTime()) break
    if (rule.until && earliest.getTime() > rule.until.getTime()) break

    let ended = false
    for (const candidate of candidatesFor(period)) {
      if (rule.count !== undefined && index >= rule.count) {
        ended = true
        break
      }
      if (rule.until && candidate.getTime() > rule.until.getTime()) {
        ended = true
        break
      }
      pushIfVisible(candidate)
      index++
      if (occurrences.length >= MAX_OCCURRENCES) {
        ended = true
        break
      }
    }
    if (ended) break
    period++
  }

  // RDATE: extra instants join the set (deduped against generated starts and
  // exclusions) with the same wall-time duration. Sorted so direct consumers
  // still receive chronological order (buildEventIndex re-sorts regardless).
  if (rule.rDates?.length) {
    const seen = new Set(occurrences.map((o) => o.start.getTime()))
    for (const rDate of rule.rDates) {
      const start = new Date(rDate.getTime())
      if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue
      const end = endFor(start)
      if (start >= range.end || end <= range.start) continue
      seen.add(start.getTime())
      occurrences.push({
        key: `${event.id}::${start.toISOString()}`,
        eventId: event.id,
        event,
        start,
        end,
        allDay,
        isRecurring: true,
        // keep counting past the generated instants: an RDATE with no index
        // would fall back to undefined and break any consumer that identifies
        // an instance by its position in the series
        recurrenceIndex: index++,
      })
    }
    occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())
  }

  return occurrences
}

export {
  EventCalendarRecurrenceError,
  expandRecurrence,
  formatRRuleString,
  MAX_OCCURRENCES,
  parseRRuleString,
}

src/reui/event-calendar/types.ts

// Title: Event Calendar Types
// Description: Public TypeScript contract for the headless event calendar: events, occurrences, segments, state, and callbacks.
// Port of ReUI event-calendar-types.tsx (MIT) — pure TS, ported verbatim (framework-agnostic).

export type EventCalendarEventId = string

export type CalendarView = "month" | "week" | "day" | "days" | "agenda" | "resource"

/**
 * A bookable resource (room, person, equipment). Nesting via children renders
 * with children; the resource day view shows leaves as booking columns.
 */
export interface EventCalendarResource {
  id: string
  title: string
  /** Token or css color used for subtle row/column accents. */
  color?: string
  children?: EventCalendarResource[]
}

export interface EventCalendarDateRange {
  /** Inclusive instant. */
  start: Date
  /** Exclusive instant. */
  end: Date
}

export type EventCalendarWeekday = "MO" | "TU" | "WE" | "TH" | "FR" | "SA" | "SU"

export interface EventCalendarRecurrenceRule {
  freq: "daily" | "weekly" | "monthly" | "yearly"
  interval?: number
  count?: number
  /** Inclusive instant. */
  until?: Date
  byWeekday?: Array<
    EventCalendarWeekday | { day: EventCalendarWeekday; ordinal: number }
  >
  byMonthDay?: number[]
  byMonth?: number[]
  weekStart?: EventCalendarWeekday
  exDates?: Date[]
  rDates?: Date[]
}

export interface CalendarEvent<TData = unknown> {
  id: EventCalendarEventId
  title: string
  /** Plain instant; consumers parse ISO strings themselves. */
  start: Date
  /** Exclusive; must be >= start. */
  end: Date
  /**
   * Start and end must be midnights in the calendar's display time zone:
   * segmentation walks the raw instants, so a row stored at another zone's
   * midnight paints on the wrong days.
   */
  allDay?: boolean
  /** Structured rule or a raw "RRULE:..." line. */
  recurrence?: EventCalendarRecurrenceRule | string
  /** This event is an edited single occurrence of that series. */
  recurringEventId?: EventCalendarEventId
  /** Which occurrence it replaces (RECURRENCE-ID semantics). */
  originalStart?: Date
  /** Token or css color; flows to the --ec-event-color css var. */
  color?: string
  /** Excluded from drag and resize regardless of interactions state. */
  readOnly?: boolean
  /** Per-event override; default comes from interactions.drag. */
  draggable?: boolean
  /** Per-event override; default comes from interactions.resize. */
  resizable?: boolean
  /** Packing prominence; feeds getEventPriority ordering. */
  priority?: number
  /** Explicit stacking override; wins over the computed z. */
  zIndex?: number
  /** Bookable resource this event belongs to (resource view). */
  resourceId?: string
  /** Consumer payload, fully generic. */
  data?: TData
}

export interface EventCalendarOccurrence<TData = unknown> {
  /** Stable per instance: `${event.id}::${startISO}`. */
  key: string
  eventId: EventCalendarEventId
  event: CalendarEvent<TData>
  start: Date
  end: Date
  allDay: boolean
  isRecurring: boolean
  recurrenceIndex?: number
}

export interface EventCalendarSegment<TData = unknown> {
  occurrence: EventCalendarOccurrence<TData>
  /** Zoned day this segment belongs to. */
  day: Date
  isStart: boolean
  isEnd: boolean
  continuesBefore: boolean
  continuesAfter: boolean
  /** Timed only: minutes from the zoned day start. */
  startMin?: number
  endMin?: number
  /** Month / all-day lane index. */
  lane?: number
  /** Time-grid overlap packing. */
  column?: number
  columnCount?: number
  columnSpan?: number
  /** Week-row granularity (month bars / all-day lanes). */
  rowIndex?: number
  colStart?: number
  colSpan?: number
}

export interface EventCalendarSelection {
  eventKeys: string[]
  /** Committed slot selection; see EventCalendarSlotDraft for the in-gesture value. */
  slot: { start: Date; end: Date; allDay: boolean } | null
}

export interface EventCalendarInteractions {
  drag: boolean
  resize: boolean
  selectSlot: boolean
}

export interface EventCalendarDragState<TData = unknown> {
  kind: "move" | "resize-start" | "resize-end"
  occurrence: EventCalendarOccurrence<TData>
  proposedStart: Date
  proposedEnd: Date
  proposedAllDay: boolean
  /**
   * True when the proposal was resolved on the day-granular surface (month
   * cells / the all-day lane) rather than minute columns - the all-day lane
   * ghost keys on this, covering timed MULTI-DAY bars whose proposals stay
   * timed (proposedAllDay alone misses them).
   */
  proposedDayGranular: boolean
  /** Target resource when dragging across resource columns/rows. */
  proposedResourceId?: string
  /** Last canDropEvent verdict; drives data-drop-invalid styling. */
  valid: boolean
}

/**
 * The in-progress drag-create rectangle ONLY, cleared on commit or cancel.
 * The committed slot selection lives in EventCalendarSelection.slot.
 */
export interface EventCalendarSlotDraft {
  start: Date
  end: Date
  allDay: boolean
  view: CalendarView
  /** Present when the slot was selected inside a resource column/row. */
  resourceId?: string
}

/**
 * User-adjustable display toggles (the "View settings" submenu). Every field
 * is optional; undefined defers to the matching root view-config prop.
 */
export interface EventCalendarViewSettings {
  /** Show Saturday/Sunday columns in month, week, and N-day grids. */
  weekends?: boolean
  /** Week-number gutter in the month view. */
  weekNumbers?: boolean
  nowIndicator?: boolean
  /** Off-day (non-working) background marking. */
  offDays?: boolean
}

export interface EventCalendarState<TData = unknown> {
  view: CalendarView
  /** Anchor date. */
  date: Date
  /** For the "days" view. */
  dayCount: number
  /** Full rendered grid incl. outside days - fetch remote data for THIS. */
  visibleRange: EventCalendarDateRange
  /** The logical period (the month/week itself). */
  activeRange: EventCalendarDateRange
  events: CalendarEvent<TData>[]
  selection: EventCalendarSelection
  interactions: EventCalendarInteractions
  loading: boolean
  drag: EventCalendarDragState<TData> | null
  slotDraft: EventCalendarSlotDraft | null
  viewSettings: EventCalendarViewSettings
}

export interface EventCalendarRangeInfo {
  range: EventCalendarDateRange
  activeRange: EventCalendarDateRange
  view: CalendarView
  date: Date
  timeZone: string
}

export interface EventCalendarProposedUpdate<TData = unknown> {
  event: CalendarEvent<TData>
  /** null when source === "api". */
  occurrence: EventCalendarOccurrence<TData> | null
  start: Date
  end: Date
  allDay: boolean
  /** Proposed resource when the gesture crossed resource columns/rows. */
  resourceId?: string
  source: "drag" | "resize-start" | "resize-end" | "keyboard" | "api"
}

/** false = reject/revert; void or true = accept; object = accept with adjustment. */
export type EventCalendarUpdateResult =
  | boolean
  | void
  | { start?: Date; end?: Date; allDay?: boolean }

/** A click is a point, not a range; `end` is present for timed slots. */
export interface EventCalendarSlotInfo {
  date: Date
  end?: Date
  allDay: boolean
  view: CalendarView
  /** Present when the click happened inside a resource column/row. */
  resourceId?: string
}

/**
 * Off-day marking (non-working days). `true` uses the defaults: weekends
 * with a muted background. Custom weekday sets, explicit dates, a predicate,
 * and a custom class are all supported; marked cells carry `data-off` for
 * CSS-selector customization.
 */
export interface EventCalendarOffDaysConfig {
  /** Weekday numbers treated as off (0 = Sunday). Default [0, 6]. */
  weekendDays?: number[]
  /** Additional explicit off dates (compared by day in the display zone). */
  dates?: Date[]
  /** Full custom predicate; runs in addition to weekendDays/dates. */
  isOffDay?: (day: Date) => boolean
  /** Marker classes; default "bg-muted/25". */
  className?: string
}

/**
 * External-data contract. v1 ships the type plus docs recipes (Google
 * events.list / MS Graph calendarView map to CalendarEvent in ~15 lines);
 * OAuth, tokens, and sync loops are application backend territory.
 */
export interface EventCalendarDataAdapter<TData = unknown> {
  getEvents(
    range: EventCalendarDateRange,
    signal?: AbortSignal
  ): Promise<CalendarEvent<TData>[]>
}

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/event-calendar.json

Зависимости реестра

npm-зависимости

  • @date-fns/tz
  • date-fns
  • reka-ui

Источник: порт из ReUI (Keenthemes, MIT)