component — date & time

Calendar Shell

The time grid every Outlook-style view is drawn from: hour axis and gridlines, an all-day band that folds into "+N more", a now-marker, and overlapping events packed into side-by-side columns. Day View, Week View and Month View are assemblies over it.

  • calendar
  • events
  • schedule
  • grid
  • agenda
  • overlap
  • shell

04 examples

One day

The shell on its own, with no toolbar: hand it the days it should draw and it draws them. Day View and Week View are this component plus a date and a toolbar.

Mi7
09 Uhr
10 Uhr
11 Uhr
12 Uhr
13 Uhr
14 Uhr
15 Uhr
16 Uhr
17 Uhr

How overlaps pack

A containing block keeps the leftmost column and widens wherever nothing sits beside it; back-to-back meetings share a column, because intervals are half-open; and a zero-length event is packed at the height it is actually drawn at, so it never hides under its neighbour.

Mi7
09 Uhr
10 Uhr
11 Uhr
12 Uhr
13 Uhr
14 Uhr
15 Uhr
16 Uhr

Several days, and one that crosses midnight

Any run of days works — three, five, fourteen. An event running past midnight is cut into one block per day and cut square at the seam, so the two halves read as one thing.

My calendarTeam
Mi7
Do8
Fr9
01 Uhr
02 Uhr
03 Uhr
04 Uhr
05 Uhr
06 Uhr
07 Uhr
08 Uhr
09 Uhr
10 Uhr
11 Uhr
12 Uhr
13 Uhr
14 Uhr
15 Uhr
16 Uhr
17 Uhr
18 Uhr
19 Uhr
20 Uhr
21 Uhr
22 Uhr
23 Uhr

Stripped back

showDayHeaders and showAllDayRow turn off the chrome for an embedded grid. Selection is controlled here, so the surrounding page decides what is highlighted.

10 Uhr
11 Uhr
12 Uhr

Selected: m1

source

calendar-shell.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import {
  type CalendarDate,
  fromDate,
  Time,
  toCalendarDate,
  toCalendarDateTime,
  toZoned,
  type ZonedDateTime,
} from "@internationalized/date"
import { useEffect, useId, useMemo, useRef, useState } from "react"
import { useMove } from "react-aria"
import { Button, Dialog, Heading, useLocale } from "react-aria-components"
import {
  type CalendarColorName,
  type CalendarEvent,
  type CalendarSource,
  type DaySegment,
  type EventBand,
  eventsOnDay,
  isAllDayEvent,
  limitLanes,
  MINUTES_PER_DAY,
  packAgainst,
  type PackedAgainst,
  packBands,
  packColumns,
  segmentByDay,
} from "@/lib/calendar"
import { getDateTimeFormat } from "@/lib/intl"
import { cn } from "@/lib/utils"
import { Checkbox, CheckboxGroup } from "@/components/checkbox"
import { Popover, PopoverContent } from "@/components/popover"

/**
 * CalendarShell — quebi design system
 *
 * The parts every Outlook-style view is drawn from: a time axis, the hour
 * gridlines, the all-day band with its "+N more" overflow — drawn when there is
 * something to put in it, see `showAllDayRow` — the now-marker, and the event
 * blocks laid out by `@/lib/calendar`'s packing pass. `DayView`,
 * `WeekView` and `MonthView` are assemblies over this; `CalendarTimeline` reuses
 * the palette and the blocks and draws its own axis, because its time runs
 * horizontally.
 *
 * This is the `TableShell` of the calendar family: it renders, it does not
 * decide. Which days are visible, what the toolbar says and who owns the date
 * are the assembly's business, and none of it is reachable from here.
 *
 * ## Display, selection and move
 *
 * What the shell reports is what a display can honestly know: `onEventClick`
 * when something is activated, `onSelectionChange` for the one event drawn as
 * selected, and — since task #182 — `onEventChange` when an event is dragged to
 * another time or another day. Move is opt-in per event through
 * `isEventEditable` and it changes nothing by itself: the shell reports the
 * drop, the consumer owns the events array, which is the contract
 * `ServerTable`'s `onQueryChange` has. `DayView` and `WeekView` pass both props
 * straight through, so the machinery is here once rather than in each view.
 *
 * Drag-to-create and drag-to-resize are still out, and that is the half of the
 * old v1 line that holds: `DaySchedule` is the component that owns dragging a
 * time span's *edges* around. `onEventChange` reports a start, an end and a
 * calendar rather than a delta so that resize can arrive behind this same prop
 * instead of beside it.
 */

/**
 * The calendar palette, as classes.
 *
 * Ink & Paper has no decorative hue, so the eight slots are eight treatments of
 * ink rather than eight colours. The names (`blue`, `orange`, `brand`, …) are
 * kept because they are API — `CalendarColorName` in `@/lib/calendar`, stored
 * in consumers' event and calendar records — but they no longer describe what
 * is drawn. Each slot is a *ground* crossed with an *edge*:
 *
 * | slot      | ground                   | edge (2px, left) | dot                  |
 * |-----------|--------------------------|------------------|----------------------|
 * | `blue`    | pressed (bg-200)         | solid ink        | filled, subtle ink   |
 * | `orange`  | hatched paper            | solid ink        | hatched, ink ring    |
 * | `brand`   | raised (bg-100)          | solid ink        | filled ink           |
 * | `amber`   | paper with a 5% ink tint | solid ink        | ink ring             |
 * | `pink`    | raised                   | dashed ink       | dashed ink ring      |
 * | `emerald` | pressed                  | dashed ink       | pressed, subtle ring |
 * | `violet`  | hatched paper            | dashed ink       | dashed subtle ring   |
 * | `rose`    | tinted paper             | dashed ink       | subtle ring          |
 *
 * In `calendarColorNames` order every adjacent pair differs in ground or edge,
 * so the assignment rule there — assign in order, never cycle — still holds.
 * Grey cannot carry eight categories on its own, and it is not asked to: every
 * block carries its title and time as text, and a timeline row is labelled
 * with its calendar's name. The treatment is the second channel.
 *
 * **Every ground is opaque.** Hour lines, sub-slot lines and column rules are
 * drawn *under* the events; a translucent block would let them through and read
 * as a pane of glass over the grid rather than an object on it. The tint is
 * therefore a `color-mix` against `--color-quebi-bg` at alpha 1, and the hatch
 * is laid over `bg-quebi-bg` rather than over nothing. Text wears text tokens
 * (`quebi-fg`, `quebi-fg-muted`) on every ground, which is why no slot is an
 * ink *fill*: the title would vanish into it.
 *
 * **Selection is a signal outline** (`selected`), never the inset ring focus
 * uses: an inset ring lands on the 2px edge and erases it, and an outline rides
 * a different CSS property, so a block both focused and selected shows both.
 *
 * The strings are written out rather than built by a helper because a Tailwind
 * v4 class only exists if its full text appears in a scanned file.
 */
export const CALENDAR_COLORS: Record<
  CalendarColorName,
  { block: string; edge: string; dot: string; band: string; selected: string }
> = {
  blue: {
    block: "bg-quebi-pressed hover:bg-[color-mix(in_oklab,var(--color-quebi-surface)_20%,var(--color-quebi-bg))]",
    edge: "border-l-quebi-fg",
    dot: "bg-quebi-fg-subtle",
    band: "bg-quebi-pressed",
    selected: "outline-quebi-signal",
  },
  orange: {
    block: "bg-quebi-bg bg-[repeating-linear-gradient(135deg,var(--color-quebi-hairline)_0_1px,transparent_1px_6px)] hover:bg-quebi-raised",
    edge: "border-l-quebi-fg",
    dot: "border border-quebi-fg bg-quebi-bg bg-[repeating-linear-gradient(135deg,var(--color-quebi-hairline)_0_1px,transparent_1px_6px)]",
    band: "bg-quebi-bg bg-[repeating-linear-gradient(135deg,var(--color-quebi-hairline)_0_1px,transparent_1px_6px)]",
    selected: "outline-quebi-signal",
  },
  brand: {
    block: "bg-quebi-raised hover:bg-quebi-pressed",
    edge: "border-l-quebi-fg",
    dot: "bg-quebi-fg",
    band: "bg-quebi-raised",
    selected: "outline-quebi-signal",
  },
  amber: {
    block: "bg-[color-mix(in_oklab,var(--color-quebi-surface)_5%,var(--color-quebi-bg))] hover:bg-quebi-raised",
    edge: "border-l-quebi-fg",
    dot: "border border-quebi-fg",
    band: "bg-[color-mix(in_oklab,var(--color-quebi-surface)_5%,var(--color-quebi-bg))]",
    selected: "outline-quebi-signal",
  },
  pink: {
    block: "bg-quebi-raised hover:bg-quebi-pressed",
    edge: "border-dashed border-l-quebi-fg",
    dot: "border border-dashed border-quebi-fg",
    band: "bg-quebi-raised",
    selected: "outline-quebi-signal",
  },
  emerald: {
    block: "bg-quebi-pressed hover:bg-[color-mix(in_oklab,var(--color-quebi-surface)_20%,var(--color-quebi-bg))]",
    edge: "border-dashed border-l-quebi-fg",
    dot: "border border-quebi-fg-subtle bg-quebi-pressed",
    band: "bg-quebi-pressed",
    selected: "outline-quebi-signal",
  },
  violet: {
    block: "bg-quebi-bg bg-[repeating-linear-gradient(135deg,var(--color-quebi-hairline)_0_1px,transparent_1px_6px)] hover:bg-quebi-raised",
    edge: "border-dashed border-l-quebi-fg",
    dot: "border border-dashed border-quebi-fg-subtle",
    band: "bg-quebi-bg bg-[repeating-linear-gradient(135deg,var(--color-quebi-hairline)_0_1px,transparent_1px_6px)]",
    selected: "outline-quebi-signal",
  },
  rose: {
    block: "bg-[color-mix(in_oklab,var(--color-quebi-surface)_5%,var(--color-quebi-bg))] hover:bg-quebi-raised",
    edge: "border-dashed border-l-quebi-fg",
    dot: "border border-quebi-fg-subtle",
    band: "bg-[color-mix(in_oklab,var(--color-quebi-surface)_5%,var(--color-quebi-bg))]",
    selected: "outline-quebi-signal",
  },
}

/** The zone every view is drawn in unless told otherwise — pinned, never read
 * from the runtime. A prerendered calendar and the browser that hydrates it are
 * two machines with two `Intl.DateTimeFormat().resolvedOptions().timeZone`s, and
 * a day grid that disagrees about which day it is re-renders every event. Same
 * argument, same default as `FormattedDate`. */
export const DEFAULT_CALENDAR_TIME_ZONE = "Europe/Berlin"

/** The event's own colour, else its calendar's, else the first slot. */
export function resolveEventColor(
  event: CalendarEvent,
  calendars: readonly CalendarSource[] | undefined,
): CalendarColorName {
  if (event.color) return event.color
  const source = calendars?.find((candidate) => candidate.id === event.calendarId)
  return source?.color ?? "brand"
}

/** `09:00` / `9:00 AM` — the locale decides, the cache builds the formatter once. */
export function formatEventTime(instant: ZonedDateTime, locale: string, timeZone: string) {
  return getDateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone }).format(
    instant.toDate(),
  )
}

/** The gutter label for an hour — `09 Uhr`, `9 AM`. */
function formatHourLabel(day: CalendarDate, hour: number, locale: string, timeZone: string) {
  const instant = toZoned(toCalendarDateTime(day, new Time(hour)), timeZone)
  return getDateTimeFormat(locale, { hour: "numeric", timeZone }).format(instant.toDate())
}

/** A `CalendarDate` as a `Date`, at midnight in `timeZone`, for a formatter. */
export function dayToDate(day: CalendarDate, timeZone: string): Date {
  return toZoned(toCalendarDateTime(day, new Time(0)), timeZone).toDate()
}

/**
 * The current instant, or `null` until the component has mounted.
 *
 * Reading the clock during render puts one time in the prerendered HTML and a
 * different one in the hydrated tree, so the now-marker is absent from the
 * server's output by construction and appears on mount. It then re-reads once a
 * minute, which is the resolution the marker is drawn at. Pass `now` explicitly
 * to pin it — the tests do, and so should anything that needs a stable
 * screenshot.
 */
export function useCalendarNowInstant(
  provided: ZonedDateTime | null | undefined,
  timeZone: string,
): ZonedDateTime | null {
  const [tick, setTick] = useState<Date | null>(null)

  useEffect(() => {
    if (provided !== undefined) return
    setTick(new Date())
    const id = setInterval(() => setTick(new Date()), 60_000)
    return () => clearInterval(id)
  }, [provided])

  return useMemo(() => {
    if (provided !== undefined) return provided
    if (!tick) return null
    // `fromDate` is the instant → wall-clock conversion done *in the grid's
    // zone*, not the runtime's: the marker sits an hour out for anyone reading a
    // Berlin calendar from London if the browser's zone is allowed in here.
    return fromDate(tick, timeZone)
  }, [provided, tick, timeZone])
}

/**
 * Today's date in `timeZone`, or `null` until mount.
 *
 * The same prerender argument as `useCalendarNowInstant`, for the views that
 * only need the day:
 * the month grid highlights today, and a build machine's idea of today is not
 * the reader's. Pass a `CalendarDate` to pin it, or `null` to highlight nothing.
 */
export function useCalendarToday(
  provided: CalendarDate | null | undefined,
  timeZone: string,
): CalendarDate | null {
  const instant = useCalendarNowInstant(provided === undefined ? undefined : null, timeZone)
  if (provided !== undefined) return provided
  return instant ? toCalendarDate(instant) : null
}

/**
 * The axis as the four numbers any conversion between minutes and pixels needs.
 *
 * The grid positions with `offsetOfMinutes` and reads a drop back with
 * `minutesFromOffset`. They are written as a pair, and the pair is why an event
 * dropped on a gridline lands on the time that line is labelled with.
 */
export interface CalendarAxis {
  /** First hour drawn. */
  startHour: number
  /** Last hour drawn, exclusive. */
  endHour: number
  /** Pixels per hour. */
  hourHeight: number
  /** The snap step in minutes — the sub-slot lines the grid draws. */
  slotMinutes: number
}

/** Clock minutes → pixels down the grid. What every block is positioned by. */
export function offsetOfMinutes(minutes: number, axis: CalendarAxis): number {
  return ((minutes - axis.startHour * 60) * axis.hourHeight) / 60
}

/**
 * Pixels down the grid → clock minutes: the inverse of `offsetOfMinutes`,
 * snapped and clamped (task #182).
 *
 * The snap is to the sub-slot the grid actually draws, anchored at the top of
 * the axis, so a dropped block sits *on* a line rather than near one — and a
 * 09:15 event dragged anywhere lands at :00 or :30 under the default
 * `slotMinutes`, because the grid it is being dropped onto has no other lines
 * on it. With `slotMinutes` at 0 the only lines are the hours and the snap
 * follows them.
 *
 * The clamp takes `durationMinutes` because what has to fit is the block, not
 * its top edge: a block lands whole inside `[startHour, endHour)` or it does
 * not land there at all. The axis is the grid's extent rather than a scroll
 * position — `startHour` says so, and `segmentByDay` cuts against it — so a
 * drop half an hour past `endHour` would hand the reader an event the grid
 * cannot draw, which is an event that vanished under their own hand. The upper
 * bound is floored back onto the snap grid, so the last legal position is still
 * on a line, and an event longer than the axis pins to the top of it.
 */
export function minutesFromOffset(top: number, axis: CalendarAxis, durationMinutes = 0): number {
  const first = axis.startHour * 60
  const last = axis.endHour * 60
  const step = axis.slotMinutes > 0 ? axis.slotMinutes : 60
  const raw = axis.hourHeight > 0 ? first + (top * 60) / axis.hourHeight : first
  const snapped = first + Math.round((raw - first) / step) * step
  const latest = first + Math.floor(Math.max(0, last - durationMinutes - first) / step) * step
  return Math.min(Math.max(snapped, first), Math.max(first, latest))
}

/**
 * Which day column `x` pixels across the grid falls in.
 *
 * The columns are equal fractions of the one `relative grid flex-1` container,
 * so the hit test needs that container's width and nothing else — which is what
 * makes a drag that leaves its own day answerable at all: the per-day wrappers
 * the blocks live in are laid out by the grid and have no coordinate space a
 * neighbouring column is expressible in. An x outside the grid clamps to the
 * first or last day rather than reporting a column that is not there, so a drag
 * off the side lands on the edge it left by.
 */
export function dayIndexAtOffset(x: number, gridWidth: number, dayCount: number): number {
  if (dayCount <= 1 || gridWidth <= 0) return 0
  return Math.min(dayCount - 1, Math.max(0, Math.floor(x / (gridWidth / dayCount))))
}

/** A `CalendarDate` plus clock minutes as an instant, rolling over midnight. */
function zonedAt(day: CalendarDate, minutes: number, timeZone: string): ZonedDateTime {
  const dayOffset = Math.floor(minutes / MINUTES_PER_DAY)
  const within = minutes - dayOffset * MINUTES_PER_DAY
  const base = dayOffset === 0 ? day : day.add({ days: dayOffset })
  return toZoned(
    toCalendarDateTime(base, new Time(Math.floor(within / 60), within % 60)),
    timeZone,
  )
}

/**
 * Where an event was dropped.
 *
 * A start, an end and a calendar rather than a delta, because the consumer owns
 * the events array and what it needs is the event it should write back — the
 * same shape `ServerTable` hands to `onQueryChange`. Move preserves the
 * duration, so `end` is `start` plus what the event already lasted; the field
 * is here for the resize that will arrive behind this same prop.
 */
export interface CalendarEventChange {
  start: ZonedDateTime
  end: ZonedDateTime
  /**
   * The calendar the event landed on, when the thing a column stands for is a
   * calendar. Undefined from the grid views, whose columns are days.
   */
  calendarId?: string
}

/** The ghost drawn at the snapped target while a drag is in flight. */
export interface CalendarMovePreview<E extends CalendarEvent = CalendarEvent> {
  event: E
  /** Column the ghost sits in. */
  dayIndex: number
  /** Clock minutes on that day — the ghost's geometry. */
  startMinutes: number
  endMinutes: number
  /** The drop it stands for, which is what `onEventChange` is handed. */
  start: ZonedDateTime
  end: ZonedDateTime
}

/**
 * How far a pointer has to travel before the gesture is a drag and not a click.
 *
 * Exported because `MonthView` starts the same gesture over a grid with no
 * time axis: two views disagreeing about what counts as a drag would be two
 * different answers to "did I just click this?" in one calendar.
 */
export const DRAG_THRESHOLD = 3

interface MoveOrigin<E extends CalendarEvent> {
  event: E
  dayIndex: number
  startMinutes: number
  durationMinutes: number
  /** Page coordinates of the press. Null for a keyboard move, which has none. */
  pointer: { x: number; y: number } | null
  /** The grid's box in page coordinates, read once — see `start`. */
  grid: { left: number; width: number } | null
  deltaX: number
  deltaY: number
  target: { dayIndex: number; startMinutes: number } | null
  dragged: boolean
}

interface EventMoveOptions<E extends CalendarEvent> {
  days: readonly CalendarDate[]
  timeZone: string
  axis: CalendarAxis
  gridRef: React.RefObject<HTMLDivElement | null>
  isEventEditable: boolean | ((event: E) => boolean) | undefined
  onEventChange: ((event: E, next: CalendarEventChange) => void) | undefined
  announce: (event: E, next: CalendarMovePreview<E>) => void
  hintId: string
}

interface EventMoveController<E extends CalendarEvent> {
  /** Is anything movable at all? Gates the hint and the live region. */
  enabled: boolean
  /** The sr-only line describing the gesture, for `aria-describedby`. */
  hintId: string
  preview: CalendarMovePreview<E> | null
  isMovable: (segment: DaySegment<E>) => boolean
  start: (segment: DaySegment<E>, pointer: { x: number; y: number } | null) => void
  move: (deltaX: number, deltaY: number, pointerType: string) => void
  end: (pointerType: string) => void
  /** Was the press that just fired the tail of a drag? Consumes the flag. */
  consumePress: () => boolean
}

/** `CSS.escape`, where there is one — happy-dom and older engines have none. */
const escapeId = (id: string) =>
  typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(id) : id

/**
 * Dragging an event to another time or another day (task #182).
 *
 * Three decisions are in here rather than in the caller, because all three are
 * about what the grid can honestly draw:
 *
 * - **Nothing repacks mid-drag — but the ghost is packed.** The real blocks
 *   never leave their column while the pointer is down: `packColumns` is a
 *   pass over the whole day, and re-running it per frame would reflow the
 *   dragged event's neighbours under a reader who is looking at something
 *   else. The ghost, though, is packed against the day it is over, by
 *   `packAgainst` — it takes the column and width the drop will give it rather
 *   than the whole column, which is what a ghost the width of a day claimed
 *   every event on it was about to be displaced. The drop still reports once,
 *   the consumer re-renders, and the packer runs over the new events.
 * - **A cut segment is not movable.** `continuesBefore` / `continuesAfter` mean
 *   the block is a slice of an event whose real start is off the axis, so a
 *   drop position for *it* says nothing about where the event goes. The 22:00
 *   → 06:00 shift is the case: it draws as two blocks on two days, and neither
 *   of them is the thing you would be moving.
 * - **A press is not a drag.** react-aria's press fires from a document-level
 *   `pointerup` and `useMove`'s end from a window-level one, so the press
 *   always arrives first and cannot be suppressed from `onMoveEnd`. The flag is
 *   therefore set on the first `onMove` past `DRAG_THRESHOLD` and consumed by
 *   the press, which is what keeps a plain click selecting.
 */
function useEventMove<E extends CalendarEvent>(
  options: EventMoveOptions<E>,
): EventMoveController<E> {
  const { days, timeZone, axis, gridRef, isEventEditable, onEventChange, announce, hintId } =
    options
  const [preview, setPreview] = useState<CalendarMovePreview<E> | null>(null)
  const originRef = useRef<MoveOrigin<E> | null>(null)
  const draggedRef = useRef(false)
  const refocusRef = useRef<string | null>(null)

  // A keyboard move commits on every key press, and a commit that changes the
  // day remounts the block under another column's wrapper — React has no way to
  // carry focus across that, and a focus lost after the first ArrowRight is the
  // keyboard path gone. So the block is found again by the id it carries and
  // re-focused, once, on the render that follows the commit.
  useEffect(() => {
    const id = refocusRef.current
    if (!id) return
    refocusRef.current = null
    gridRef.current
      ?.querySelector<HTMLElement>(
        `[data-slot="calendar-event"][data-event-id="${escapeId(id)}"]`,
      )
      ?.focus()
  })

  const enabled = onEventChange !== undefined && isEventEditable !== undefined

  const isMovable = (segment: DaySegment<E>) => {
    if (!enabled) return false
    if (segment.continuesBefore || segment.continuesAfter) return false
    return typeof isEventEditable === "function"
      ? isEventEditable(segment.event)
      : isEventEditable === true
  }

  const previewAt = (
    origin: MoveOrigin<E>,
    dayIndex: number,
    startMinutes: number,
  ): CalendarMovePreview<E> | null => {
    const day = days[dayIndex]
    if (!day) return null
    const start = zonedAt(day, startMinutes, timeZone)
    return {
      event: origin.event,
      dayIndex,
      startMinutes,
      endMinutes: startMinutes + origin.durationMinutes,
      start,
      // Wall-clock arithmetic, so a 90-minute meeting is 90 minutes on the
      // clock it is read from even on the day a zone changes offset.
      end: start.add({ minutes: origin.durationMinutes }),
    }
  }

  const commit = (origin: MoveOrigin<E>, pointerType: string) => {
    const target = origin.target
    if (!target) return
    if (target.dayIndex === origin.dayIndex && target.startMinutes === origin.startMinutes) return
    const next = previewAt(origin, target.dayIndex, target.startMinutes)
    if (!next) return
    if (pointerType === "keyboard") refocusRef.current = origin.event.id
    onEventChange?.(origin.event, { start: next.start, end: next.end })
    announce(origin.event, next)
  }

  return {
    enabled,
    hintId,
    preview,
    isMovable,

    start(segment, pointer) {
      draggedRef.current = false
      if (!isMovable(segment)) {
        originRef.current = null
        return
      }
      // The grid's box is read once, at the press. Reading it per frame would
      // pick up the scroll the drag itself can cause and walk the ghost.
      const rect = gridRef.current?.getBoundingClientRect()
      originRef.current = {
        event: segment.event,
        dayIndex: segment.dayIndex,
        startMinutes: segment.start,
        durationMinutes: segment.end - segment.start,
        pointer,
        grid: rect ? { left: rect.left + window.scrollX, width: rect.width } : null,
        deltaX: 0,
        deltaY: 0,
        target: null,
        dragged: false,
      }
    },

    move(deltaX, deltaY, pointerType) {
      const origin = originRef.current
      if (!origin) return

      // `useMove` reports one unit per arrow press whatever the pointer type,
      // so the keyboard's unit is chosen here: a slot down the axis, a day
      // across it. Each press commits on its own `moveend`, which is what makes
      // the move announceable step by step instead of only at the end.
      if (pointerType === "keyboard") {
        const step = axis.slotMinutes > 0 ? axis.slotMinutes : 60
        origin.target = {
          dayIndex: Math.min(days.length - 1, Math.max(0, origin.dayIndex + Math.sign(deltaX))),
          startMinutes: minutesFromOffset(
            offsetOfMinutes(origin.startMinutes + Math.sign(deltaY) * step, axis),
            axis,
            origin.durationMinutes,
          ),
        }
        return
      }

      origin.deltaX += deltaX
      origin.deltaY += deltaY
      if (
        Math.abs(origin.deltaX) > DRAG_THRESHOLD ||
        Math.abs(origin.deltaY) > DRAG_THRESHOLD
      ) {
        origin.dragged = true
        draggedRef.current = true
      }

      // An unmeasurable grid keeps the event in its own column rather than
      // guessing at the first one: a drag that cannot answer "which day" has
      // not been told the event moved days.
      const dayIndex =
        origin.grid && origin.grid.width > 0 && origin.pointer
          ? dayIndexAtOffset(
              origin.pointer.x + origin.deltaX - origin.grid.left,
              origin.grid.width,
              days.length,
            )
          : origin.dayIndex
      const startMinutes = minutesFromOffset(
        offsetOfMinutes(origin.startMinutes, axis) + origin.deltaY,
        axis,
        origin.durationMinutes,
      )
      origin.target = { dayIndex, startMinutes }
      setPreview(previewAt(origin, dayIndex, startMinutes))
    },

    end(pointerType) {
      const origin = originRef.current
      originRef.current = null
      setPreview(null)
      if (!origin) return
      // A wobble under the threshold is the click it looked like, and the press
      // that follows it is left to do its job.
      if (pointerType !== "keyboard" && !origin.dragged) return
      commit(origin, pointerType)
    },

    consumePress() {
      if (!draggedRef.current) return false
      draggedRef.current = false
      return true
    },
  }
}

export interface CalendarShellProps<E extends CalendarEvent = CalendarEvent> {
  /** The visible days, left to right. One for a day view, seven for a week. */
  days: readonly CalendarDate[]
  events: readonly E[]
  /** Colour and naming for the calendars the events belong to. */
  calendars?: readonly CalendarSource[]
  /** IANA zone the grid is drawn in. Pinned by default — see the constant. */
  timeZone?: string
  /** BCP 47 tag. Defaults to the nearest `I18nProvider`'s locale. */
  locale?: string
  /**
   * First hour on the axis. Default 0.
   *
   * The axis is the grid's extent, not a scroll position: an event outside
   * `[startHour, endHour)` is not drawn, and one reaching across either edge is
   * cut at it and gets the same flat edge a midnight crossing does. Narrow the
   * axis and you are choosing what the grid shows, so pair it with a filter the
   * reader can see if events can fall outside it.
   */
  startHour?: number
  /** Last hour on the axis, exclusive. Default 24. See `startHour`. */
  endHour?: number
  /** Pixels per hour. Default 48. */
  hourHeight?: number
  /** Minutes between the faint intermediate gridlines. Default 30. */
  slotMinutes?: number
  /** Width of the time gutter in pixels. Default 60. */
  axisWidth?: number
  /** Height of the scrolling area in pixels. Default 520. */
  height?: number
  /** Pin the now-marker, or pass `null` to omit it. Undefined reads the clock after mount. */
  now?: ZonedDateTime | null
  /**
   * Draw the all-day band above the grid. Default `"auto"`.
   *
   * `"auto"` draws it when `events` holds anything the band would ever take —
   * an `allDay` event, or one 24 hours or longer, by `isAllDayEvent` — and
   * omits it otherwise. The question is asked of the whole set rather than of
   * the days on show, so paging through a calendar that has all-day events
   * never makes the row appear and disappear under the reader: it is reserved
   * from the first render, empty weeks included. A calendar that has none of
   * them has nothing to reserve it for, and the empty 28px strip above the grid
   * is chrome for a feature that consumer does not have.
   *
   * `true` keeps the row whatever the events say — the setting for a view that
   * loads one week at a time, where "no all-day event" is a fact about the page
   * in hand rather than about the calendar. `false` never draws it.
   */
  showAllDayRow?: boolean | "auto"
  /** Lanes the all-day band may grow to before it folds into "+N more". Default 2. */
  maxAllDayLanes?: number
  /** Accessible name and gutter label for the all-day band. */
  allDayLabel?: string
  /** `(n) => "+2 more"`. */
  moreLabel?: (count: number) => string
  /** Draw the weekday/date row above the grid. Default true. */
  showDayHeaders?: boolean
  /** Replace the default weekday + date header for one column. */
  renderDayHeader?: (day: CalendarDate, index: number) => React.ReactNode
  /** The event drawn as selected. Controlled. */
  selectedEventId?: string | null
  /** The initially selected event, for the uncontrolled case. */
  defaultSelectedEventId?: string | null
  /** Fires with the new selection, or `null` when the selected event is clicked again. */
  onSelectionChange?: (id: string | null) => void
  /** Fires on every activation, selected or not. */
  onEventClick?: (event: E) => void
  /** Fires when an overflow link is activated, with everything on that day. */
  onMoreClick?: (day: CalendarDate, events: E[]) => void
  /**
   * Which events may be dragged to another time or another day. Default none.
   *
   * Opt-in per event, because "can this be moved" is a question about the
   * event and not about the view: a meeting someone else owns, a booking past
   * its cut-off and a holiday are all things a calendar draws and nobody may
   * drag. Pass `true` for all of them, or a predicate.
   *
   * It takes two to turn the gesture on: without `onEventChange` a drop has
   * nowhere to go, so nothing is movable however this prop reads. An event the
   * axis *cuts* — a shift running past midnight, drawn as two blocks — is not
   * movable either, whatever the predicate says, because neither of its blocks
   * is the event you would be moving.
   */
  isEventEditable?: boolean | ((event: E) => boolean)
  /**
   * Fires once on drop, with the event and where it landed.
   *
   * The shell moves nothing. It reports the drop and re-renders from the
   * `events` you hand back, the same contract `ServerTable`'s `onQueryChange`
   * has — so an app that validates the move, refuses it, or rounds it to its
   * own booking grid does that by not writing the event it was given, and the
   * block stays where it was with nothing to undo.
   *
   * The duration is preserved, and `calendarId` is undefined from the grid
   * views: their columns are days.
   */
  onEventChange?: (event: E, next: CalendarEventChange) => void
  /** What a screen reader is told a movable event can do. */
  moveHintLabel?: string
  /** The line announced after a move. Defaults to the event and its new slot. */
  moveAnnouncement?: (event: E, next: CalendarEventChange) => string
  className?: string
}

/**
 * The time grid: a gutter, `days.length` columns, and the events packed into
 * them. Every geometric decision here comes out of `@/lib/calendar`; what is
 * left is turning minutes into pixels.
 */
export function CalendarShell<E extends CalendarEvent = CalendarEvent>({
  days,
  events,
  calendars,
  timeZone = DEFAULT_CALENDAR_TIME_ZONE,
  locale: localeProp,
  startHour = 0,
  endHour = 24,
  hourHeight = 48,
  slotMinutes = 30,
  axisWidth = 60,
  height = 520,
  now,
  showAllDayRow = "auto",
  maxAllDayLanes = 2,
  allDayLabel = "all day",
  moreLabel = (count) => `+${count} more`,
  showDayHeaders = true,
  renderDayHeader,
  selectedEventId,
  defaultSelectedEventId = null,
  onSelectionChange,
  onEventClick,
  onMoreClick,
  isEventEditable,
  onEventChange,
  moveHintLabel = "Press the arrow keys to move this event.",
  moveAnnouncement,
  className,
}: CalendarShellProps<E>) {
  const { locale: contextLocale } = useLocale()
  const locale = localeProp ?? contextLocale
  const selection = useSelection(selectedEventId, defaultSelectedEventId, onSelectionChange)
  const currentInstant = useCalendarNowInstant(now, timeZone)

  const axisStart = Math.max(0, Math.min(23, Math.trunc(startHour)))
  const axisEnd = Math.max(axisStart + 1, Math.min(24, Math.trunc(endHour)))
  const gridHeight = (axisEnd - axisStart) * hourHeight
  const axis: CalendarAxis = {
    startHour: axisStart,
    endHour: axisEnd,
    hourHeight,
    slotMinutes,
  }

  // Cut against the axis, not against midnight: `toTop` is a linear map with no
  // clamp in it, so a segment the window does not contain is drawn outside the
  // grid rather than not at all — see `DayWindow`.
  const segments = useMemo(
    () =>
      segmentByDay(events, days, timeZone, {
        startMinute: axisStart * 60,
        endMinute: axisEnd * 60,
      }),
    [events, days, timeZone, axisStart, axisEnd],
  )
  const packed = useMemo(() => packColumns(segments), [segments])
  const bands = useMemo(() => packBands(events, days, timeZone), [events, days, timeZone])
  const limited = useMemo(
    () => limitLanes(bands, days.length, maxAllDayLanes),
    [bands, days.length, maxAllDayLanes],
  )

  // Asked of `events`, not of `bands`: `bands` is what the visible days hold,
  // and a row that came and went as the week changed would move the grid under
  // the reader on every step. See `showAllDayRow`.
  const hasAllDayEvents = useMemo(() => events.some(isAllDayEvent), [events])
  const allDayRow = showAllDayRow === "auto" ? hasAllDayEvents : showAllDayRow

  // One direction of the pair. `minutesFromOffset` is the other, and a drag
  // reads its drop back through it.
  const toTop = (minutes: number) => offsetOfMinutes(minutes, axis)
  const hours = Array.from({ length: axisEnd - axisStart }, (_, index) => axisStart + index)
  const firstDay = days[0]

  // Where the now-marker goes, or `null` when there is no line to draw on any
  // column: no clock read yet, or a reading this axis does not cover. The second
  // half is the guard `CalendarTimeline` has always had — without it 19:04 on a
  // 09:00–13:00 axis is placed 388px below the last gridline, which is scrollable
  // space the grid never drew (task #161).
  const markerMinutes = currentInstant ? currentInstant.hour * 60 + currentInstant.minute : null
  const markerTop =
    markerMinutes !== null && markerMinutes >= axisStart * 60 && markerMinutes <= axisEnd * 60
      ? toTop(markerMinutes)
      : null

  const columns = { gridTemplateColumns: `repeat(${Math.max(1, days.length)}, minmax(0, 1fr))` }

  const gridRef = useRef<HTMLDivElement>(null)
  const hintId = useId()
  const [announcement, setAnnouncement] = useState("")
  const move = useEventMove<E>({
    days,
    timeZone,
    axis,
    gridRef,
    isEventEditable,
    onEventChange,
    hintId,
    announce: (event, next) =>
      setAnnouncement(
        moveAnnouncement
          ? moveAnnouncement(event, { start: next.start, end: next.end })
          : `${event.title}: ${getDateTimeFormat(locale, {
              weekday: "long",
              day: "numeric",
              month: "long",
              timeZone,
            }).format(next.start.toDate())}, ${formatEventTime(next.start, locale, timeZone)} – ${formatEventTime(next.end, locale, timeZone)}`,
      ),
  })

  const activate = (event: E) => {
    selection.toggle(event.id)
    onEventClick?.(event)
  }

  return (
    <div
      data-slot="calendar-shell"
      className={cn(
        "flex w-full flex-col overflow-hidden",
        "border border-quebi-hairline bg-quebi-bg",
        className,
      )}
    >
      {showDayHeaders ? (
        <div className="flex border-quebi-hairline border-b">
          <div className="shrink-0" style={{ width: axisWidth }} />
          <div className="grid flex-1" style={columns}>
            {days.map((day, index) => (
              <div
                key={day.toString()}
                data-slot="calendar-day-header"
                className="border-quebi-hairline border-l px-2 py-2 text-center first:border-l-0"
              >
                {renderDayHeader ? (
                  renderDayHeader(day, index)
                ) : (
                  <DayHeading
                    day={day}
                    locale={locale}
                    timeZone={timeZone}
                    isToday={isSameDayAs(currentInstant, day)}
                  />
                )}
              </div>
            ))}
          </div>
        </div>
      ) : null}

      {allDayRow ? (
        <div data-slot="calendar-all-day-row" className="flex border-quebi-hairline border-b">
          <div
            className="shrink-0 px-2 py-1 text-right text-quebi-fg-subtle text-xs"
            style={{ width: axisWidth }}
          >
            {allDayLabel}
          </div>
          <AllDayBand
            days={days}
            bands={limited.bands}
            hiddenPerDay={limited.hiddenPerDay}
            allBands={bands}
            segments={segments}
            calendars={calendars}
            locale={locale}
            timeZone={timeZone}
            columns={columns}
            selectedId={selection.value}
            moreLabel={moreLabel}
            onActivate={activate}
            onMoreClick={onMoreClick}
          />
        </div>
      ) : null}

      {/* The one scrolling surface in the shell. `quebi-scrollbar` is the
          library's bar rather than the platform's; see the utility in
          `quebi-theme.css`. No `quebi-scrollbar-corners`: the viewport and the
          shell around it are both square, so there is no corner to clear. */}
      <div
        data-slot="calendar-viewport"
        className="quebi-scrollbar relative overflow-y-auto"
        style={{ maxHeight: height }}
      >
        <div className="flex" style={{ height: gridHeight }}>
          <div className="relative shrink-0" style={{ width: axisWidth }}>
            {firstDay
              ? hours.map((hour) => (
                  <div
                    key={hour}
                    className="-translate-y-1/2 absolute right-2 font-mono text-quebi-fg-subtle text-xs tabular-nums"
                    style={{ top: toTop(hour * 60) }}
                  >
                    {hour === axisStart ? null : formatHourLabel(firstDay, hour, locale, timeZone)}
                  </div>
                ))
              : null}
          </div>

          <div
            ref={gridRef}
            data-slot="calendar-grid"
            className="relative grid flex-1"
            style={columns}
          >
            <div className="pointer-events-none absolute inset-0" aria-hidden="true">
              {hours.map((hour) => (
                <div
                  key={hour}
                  className="absolute inset-x-0 border-quebi-hairline border-t"
                  style={{ top: toTop(hour * 60) }}
                />
              ))}
              {slotMinutes > 0 && slotMinutes < 60
                ? hours.flatMap((hour) =>
                    subSlots(slotMinutes).map((offset) => (
                      <div
                        key={`${hour}:${offset}`}
                        className="absolute inset-x-0 border-quebi-hairline border-t"
                        style={{ top: toTop(hour * 60 + offset) }}
                      />
                    )),
                  )
                : null}
            </div>

            {days.map((day, index) => (
              <div
                key={day.toString()}
                className="relative border-quebi-hairline border-l first:border-l-0"
              >
                {packed
                  .filter((segment) => segment.dayIndex === index)
                  .map((segment) => (
                    <TimedBlock
                      key={`${segment.event.id}:${segment.dayIndex}`}
                      segment={segment}
                      color={resolveEventColor(segment.event, calendars)}
                      top={toTop(segment.start)}
                      bottom={toTop(segment.end)}
                      gridHeight={gridHeight}
                      locale={locale}
                      timeZone={timeZone}
                      isSelected={selection.value === segment.event.id}
                      isMovable={move.isMovable(segment)}
                      isDragging={move.preview?.event.id === segment.event.id}
                      move={move}
                      onActivate={activate}
                    />
                  ))}
                {markerTop !== null && currentInstant && isSameDayAs(currentInstant, day) ? (
                  <NowMarker
                    top={markerTop}
                    label={formatEventTime(currentInstant, locale, timeZone)}
                  />
                ) : null}
              </div>
            ))}

            {move.preview ? (
              <MovePreviewBlock
                preview={move.preview}
                placement={previewPlacement(segments, move.preview)}
                color={resolveEventColor(move.preview.event, calendars)}
                dayCount={Math.max(1, days.length)}
                top={toTop(move.preview.startMinutes)}
                bottom={toTop(move.preview.endMinutes)}
                gridHeight={gridHeight}
                locale={locale}
                timeZone={timeZone}
              />
            ) : null}
          </div>
        </div>
      </div>

      {/* The keyboard half of the gesture needs saying out loud, twice over:
          a movable block is described as movable before anyone tries, and
          every landing is announced, because the ghost and the snapped
          position are pictures and a picture is not a channel every reader
          has. The region is `polite` — a move is the reader's own doing, so it
          waits its turn rather than interrupting. */}
      {move.enabled ? (
        <>
          <span id={hintId} className="sr-only">
            {moveHintLabel}
          </span>
          <span role="status" aria-live="polite" className="sr-only">
            {announcement}
          </span>
        </>
      ) : null}
    </div>
  )
}

/** Faint lines inside an hour, at `every` minutes — 30 gives the half-hour rule. */
function subSlots(every: number): number[] {
  const offsets: number[] = []
  for (let minute = every; minute < 60; minute += every) offsets.push(minute)
  return offsets
}

/** Is `instant` on `day`, read in `timeZone`? Used for "today" and the marker. */
function isSameDayAs(instant: ZonedDateTime | null, day: CalendarDate): boolean {
  if (!instant) return false
  return toCalendarDate(instant).compare(day) === 0
}

/** Controlled-or-not selection of a single event id. */
function useSelection(
  controlled: string | null | undefined,
  initial: string | null,
  onChange: ((id: string | null) => void) | undefined,
) {
  const [uncontrolled, setUncontrolled] = useState<string | null>(initial)
  const value = controlled === undefined ? uncontrolled : controlled

  return {
    value,
    toggle(id: string) {
      const next = value === id ? null : id
      if (controlled === undefined) setUncontrolled(next)
      onChange?.(next)
    },
  }
}

interface DayHeadingProps {
  day: CalendarDate
  locale: string
  timeZone: string
  isToday: boolean
}

/** `Mo` over `21`, with today's number filled in ink — the column header has
 * no selection to confuse it with, so "current" takes the ink fill here. */
export function DayHeading({ day, locale, timeZone, isToday }: DayHeadingProps) {
  const date = dayToDate(day, timeZone)
  return (
    <div className="flex flex-col items-center gap-0.5">
      <span className="quebi-eyebrow">
        {getDateTimeFormat(locale, { weekday: "short", timeZone }).format(date)}
      </span>
      <span
        className={cn(
          "flex h-7 w-7 items-center justify-center rounded-(--q-radius-mark) text-sm tabular-nums",
          isToday ? "bg-quebi-action text-quebi-on-action" : "text-quebi-fg",
        )}
      >
        {getDateTimeFormat(locale, { day: "numeric", timeZone }).format(date)}
      </span>
    </div>
  )
}

interface TimedBlockProps<E extends CalendarEvent> {
  segment: DaySegment<E> & { column: number; columns: number; span: number }
  color: CalendarColorName
  top: number
  bottom: number
  /** The axis's full height, so a block short enough to need padding stays in it. */
  gridHeight: number
  locale: string
  timeZone: string
  isSelected: boolean
  /** May this block be dragged? See `isEventEditable`. */
  isMovable: boolean
  /** Is this the block the ghost currently stands for? */
  isDragging: boolean
  move: EventMoveController<E>
  onActivate: (event: E) => void
}

/** The shortest block that still holds a line of text. */
const MIN_BLOCK_HEIGHT = 18

const NO_OP = () => {}

/**
 * The event, minus its ability to cancel the press it may still turn out to be.
 *
 * `useMove` stops propagation and prevents the default on `pointerdown`, which
 * is right when it owns the element and wrong when it is being started from
 * above one that is also a button: stopping propagation there would cancel the
 * press, and preventing the default would cancel the click the press is
 * triggered from. Everything else about the event is passed through.
 *
 * Exported for `MonthView`, whose chips are the same react-aria `Button` under
 * the same gesture. The proxy is the subtle half of starting a move from the
 * capture phase, and a second copy of it is a second thing to get wrong.
 */
export function withoutCancelling<T extends Element>(
  event: React.PointerEvent<T>,
): React.PointerEvent<T> {
  return new Proxy(event, {
    get(target, key) {
      if (key === "stopPropagation" || key === "preventDefault") return NO_OP
      const value = Reflect.get(target, key) as unknown
      return typeof value === "function" ? value.bind(target) : value
    },
  })
}

/** The keys `useMove` turns into a move, and so the keys that start one. */
export const MOVE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right"])

function TimedBlock<E extends CalendarEvent>({
  segment,
  color,
  top,
  bottom,
  gridHeight,
  locale,
  timeZone,
  isSelected,
  isMovable,
  isDragging,
  move,
  onActivate,
}: TimedBlockProps<E>) {
  const palette = CALENDAR_COLORS[color]
  const height = Math.max(MIN_BLOCK_HEIGHT, bottom - top)
  // The minimum grows a short block downwards, so a five-minute event against
  // the end of the axis would hang past the last gridline — the same escape the
  // now-marker used to make, in miniature. Push it up onto the axis instead.
  const y = Math.max(0, Math.min(top, gridHeight - height))
  const left = (segment.column / segment.columns) * 100
  const width = (segment.span / segment.columns) * 100
  // The 2px inset is the negative space that separates touching blocks. A
  // border round each one would be ink that is not data — see the palette note
  // above — and two adjacent borders read as one thick divider.
  const geometry = { top: y, height, left: `${left}%`, width: `calc(${width}% - 2px)` }
  // A selected block has to paint above its neighbours rather than merely
  // above the ones that happen to precede it. The 2px inset is horizontal
  // only — vertically two blocks share an edge exactly, an event ending at
  // noon and the one starting there — and the selection outline sits
  // *outside* the border box, so the later of the two painted its own
  // background over the earlier one's outline. One step, not ten: the
  // now-marker and the drag ghost are z-10 and stay above a selected block.
  const raised = isSelected ? "z-[1]" : undefined

  // `useMove` rather than a raw `onPointerDown`, for two reasons that are the
  // same reason: it is the hook that already knows the difference between a
  // pointer and an arrow key, and the block it would be attached to is a
  // react-aria `Button` that captures the pointer for its own press handling.
  // What it gives back is pointer *and* keyboard movement from one surface —
  // `DaySchedule` had to hand-roll `role="slider"` plus arrow keys to get the
  // same thing, and got it for one component.
  const { moveProps } = useMove({
    onMove: (event) => move.move(event.deltaX, event.deltaY, event.pointerType),
    onMoveEnd: (event) => move.end(event.pointerType),
  })

  const block = (
    <Button
      data-slot="calendar-event"
      data-event-id={segment.event.id}
      data-dragging={isDragging || undefined}
      aria-describedby={isMovable ? move.hintId : undefined}
      onPress={() => {
        // The press that ends a drag is still a press. See `useEventMove`.
        if (isMovable && move.consumePress()) return
        onActivate(segment.event)
      }}
      style={isMovable ? undefined : geometry}
      className={cn(
        "cursor-pointer overflow-hidden text-left",
        "border-l-2 px-1.5 py-0.5 transition-colors duration-150",
        "outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
        // Movable blocks are positioned by the wrapper that carries the
        // gesture, and fill it; everything else positions itself.
        // The raise goes on whichever element carries the geometry: a static
        // button inside the gesture wrapper has no z-index of its own.
        isMovable ? "h-full w-full cursor-grab active:cursor-grabbing" : cn("absolute", raised),
        palette.block,
        palette.edge,
        // Selection is an outline, not the inset ring focus uses: it sits
        // outside the border box, so it neither overpaints `edge` nor
        // vanishes when the same block takes focus. `outline-solid` is
        // load-bearing — it is what displaces the `outline-none` above,
        // which would otherwise leave the outline styled away.
        isSelected && cn("outline-2 outline-solid outline-offset-0", palette.selected),
        // The block being dragged stays where it is and steps back; the ghost
        // is the thing that moves. Repacking it into its new column per frame
        // would reflow its neighbours under a reader watching the ghost.
        isDragging && "opacity-40",
      )}
    >
      <BlockText
        event={segment.event}
        locale={locale}
        timeZone={timeZone}
        height={height}
      />
    </Button>
  )

  if (!isMovable) return block

  // `touch-none` is what makes a touch drag a drag: without it the browser
  // claims the gesture for scrolling before `useMove` sees the second frame.
  return (
    <div
      data-slot="calendar-event-move"
      className={cn("absolute touch-none", raised)}
      style={geometry}
      {...moveProps}
      onPointerDownCapture={(event) => {
        if (event.button !== 0) return
        move.start(segment, { x: event.pageX, y: event.pageY })
        // react-aria's press handling stops `pointerdown` dead on the block
        // itself — `usePress` calls `stopPropagation` unless a press handler
        // opts out, and none of them do — so the bubble-phase listener
        // `useMove` installs on this wrapper would never see one. Starting the
        // move from the capture phase is what gets both halves: the gesture
        // begins here, and the event carries on down to the button where the
        // press it might still be is waiting for it. `useMove` ignores a second
        // `pointerdown` while a pointer is down, so the listener it also
        // installed costs nothing.
        moveProps.onPointerDown?.(withoutCancelling(event))
      }}
      onKeyDownCapture={(event) => {
        if (MOVE_KEYS.has(event.key)) move.start(segment, null)
      }}
    >
      {block}
    </div>
  )
}

/**
 * Which column of its target day the ghost gets, and how wide.
 *
 * The dragged event is dropped from the day's segments before the packing, not
 * left in: on a drag that stays on its own day its old block is the one thing
 * the ghost is guaranteed *not* to collide with, and packing against it would
 * narrow the ghost to half a column for a move of half an hour.
 */
function previewPlacement<E extends CalendarEvent>(
  segments: readonly DaySegment<E>[],
  preview: CalendarMovePreview<E>,
): PackedAgainst {
  const neighbours = segments
    .filter(
      (segment) =>
        segment.dayIndex === preview.dayIndex && segment.event.id !== preview.event.id,
    )
    .map((segment) => ({ start: segment.start, end: segment.end }))

  return packAgainst(neighbours, { start: preview.startMinutes, end: preview.endMinutes })
}

interface MovePreviewBlockProps<E extends CalendarEvent> {
  preview: CalendarMovePreview<E>
  /** The column of its day the drop would put it in. See `previewPlacement`. */
  placement: PackedAgainst
  color: CalendarColorName
  dayCount: number
  top: number
  bottom: number
  /** The axis's full height, so a short ghost stays on it — as `TimedBlock` does. */
  gridHeight: number
  locale: string
  timeZone: string
}

/**
 * The ghost at the snapped target, labelled with the time it would land on.
 *
 * It is drawn in the grid container rather than in a day column, because the
 * column is the one place a drag that left it cannot be drawn: the per-day
 * wrappers are grid cells with no coordinate space their neighbours are
 * expressible in. One box across the whole grid is that space — and with the
 * ghost carrying every frame of the feedback, the real blocks never have to
 * leave their wrapper, which is what kept the layout all four views share out
 * of this change.
 *
 * Its geometry is `TimedBlock`'s, one nesting deeper: the day is a `1/dayCount`
 * slice of the grid, and the ghost's column is a `span/columns` slice of that
 * day. A ghost the full width of its day was the drag's one dishonest frame —
 * it covered every block it was about to be packed beside, so the reader could
 * not see the collision they were dropping into, and it promised a width the
 * drop then took away.
 *
 * One frame is still approximate, and deliberately: where the drop would *add*
 * a column to a cluster, the ghost takes its post-drop rectangle while the
 * blocks already there are still drawn at their pre-drop width, so it is laid
 * over the one that is about to narrow. Making that exact means repacking the
 * day per frame, which is the reflow `useEventMove` refuses — and the ghost
 * being where the block lands is the half of the picture the reader is
 * actually aiming with.
 */
function MovePreviewBlock<E extends CalendarEvent>({
  preview,
  placement,
  color,
  dayCount,
  top,
  bottom,
  gridHeight,
  locale,
  timeZone,
}: MovePreviewBlockProps<E>) {
  const palette = CALENDAR_COLORS[color]
  const height = Math.max(MIN_BLOCK_HEIGHT, bottom - top)
  // The same push as `TimedBlock`'s: the minimum grows a short block downwards,
  // and a fifteen-minute drop against the end of the axis would hang past the
  // last gridline the grid drew.
  const y = Math.max(0, Math.min(top, gridHeight - height))
  const dayWidth = 100 / dayCount
  const left = (preview.dayIndex + placement.column / placement.columns) * dayWidth
  const width = (placement.span / placement.columns) * dayWidth

  return (
    <div
      data-slot="calendar-move-preview"
      data-column={placement.column}
      data-columns={placement.columns}
      // The ghost is a picture of the drag in progress; the drop itself is
      // announced through the shell's live region, where it is one line rather
      // than one per frame.
      aria-hidden="true"
      style={{
        top: y,
        height,
        left: `${left}%`,
        // The 2px inset is `TimedBlock`'s, for the same reason: it is the
        // negative space that separates the ghost from the block beside it.
        width: `calc(${width}% - 2px)`,
      }}
      className={cn(
        "pointer-events-none absolute z-10 overflow-hidden px-1.5 py-0.5",
        "outline-2 outline-quebi-focus outline-dashed",
        palette.block,
        palette.edge,
        "border-l-2",
      )}
    >
      <span className="flex flex-col gap-0.5 text-xs leading-tight">
        <span className="truncate font-medium text-quebi-fg">{preview.event.title}</span>
        <span className="truncate text-quebi-fg-muted tabular-nums">
          {formatEventTime(preview.start, locale, timeZone)}
          {" – "}
          {formatEventTime(preview.end, locale, timeZone)}
        </span>
      </span>
    </div>
  )
}

interface BlockTextProps {
  event: CalendarEvent
  locale: string
  timeZone: string
  height: number
}

/**
 * Title, time and location, dropped one at a time as the block gets shorter.
 *
 * Text is what makes the colour a second channel rather than the only one, so
 * the title is never the thing that goes: a 20-pixel block is title-only, and
 * the time moves onto the same line where there is room for it.
 */
function BlockText({ event, locale, timeZone, height }: BlockTextProps) {
  const time = formatEventTime(event.start, locale, timeZone)

  if (height < 34) {
    return (
      <span className="flex items-baseline gap-1.5 truncate text-xs leading-tight">
        <span className="font-medium text-quebi-fg">{event.title}</span>
        <span className="text-quebi-fg-subtle tabular-nums">{time}</span>
      </span>
    )
  }

  return (
    <span className="flex flex-col gap-0.5 text-xs leading-tight">
      <span className="truncate font-medium text-quebi-fg">{event.title}</span>
      <span className="truncate text-quebi-fg-muted tabular-nums">
        {time}
        {" – "}
        {formatEventTime(event.end, locale, timeZone)}
      </span>
      {event.location && height >= 56 ? (
        <span className="truncate text-quebi-fg-subtle">{event.location}</span>
      ) : null}
    </span>
  )
}

/** The ink line across today's column, with the clock time in the gutter end. */
function NowMarker({ top, label }: { top: number; label: string }) {
  return (
    <div
      data-slot="calendar-now-marker"
      className="pointer-events-none absolute inset-x-0 z-10 flex items-center"
      style={{ top }}
    >
      {/* The line is the sighted channel and the text is the other one: a
          horizontal rule at a y offset is not information a screen reader can
          recover, and `aria-label` on a plain div is dropped. */}
      <span className="sr-only">{label}</span>
      <span className="-ml-1 h-2 w-2 shrink-0 rounded-full bg-quebi-fg" aria-hidden="true" />
      <span className="h-px flex-1 bg-quebi-fg" aria-hidden="true" />
    </div>
  )
}

interface AllDayBandProps<E extends CalendarEvent> {
  days: readonly CalendarDate[]
  bands: EventBand<E>[]
  allBands: EventBand<E>[]
  segments: readonly DaySegment<E>[]
  hiddenPerDay: number[]
  calendars: readonly CalendarSource[] | undefined
  locale: string
  timeZone: string
  columns: React.CSSProperties
  selectedId: string | null
  moreLabel: (count: number) => string
  onActivate: (event: E) => void
  onMoreClick: ((day: CalendarDate, events: E[]) => void) | undefined
}

/** The band above the grid: multi-day and all-day events, stacked in lanes. */
function AllDayBand<E extends CalendarEvent>({
  days,
  bands,
  allBands,
  segments,
  hiddenPerDay,
  calendars,
  locale,
  timeZone,
  columns,
  selectedId,
  moreLabel,
  onActivate,
  onMoreClick,
}: AllDayBandProps<E>) {
  const lanes = bands.reduce((max, band) => Math.max(max, band.lane + 1), 0)
  const overflow = hiddenPerDay.some((count) => count > 0)

  return (
    <div className="relative flex-1 py-1" style={{ minHeight: 28 }}>
      <div className="grid" style={columns}>
        {days.map((day) => (
          <div key={day.toString()} className="border-quebi-hairline border-l first:border-l-0">
            <div style={{ height: lanes * 22 }} />
          </div>
        ))}
      </div>

      {bands.map((band) => {
        const palette = CALENDAR_COLORS[resolveEventColor(band.event, calendars)]
        const left = (band.startIndex / days.length) * 100
        const width = ((band.endIndex - band.startIndex + 1) / days.length) * 100
        return (
          <Button
            key={band.event.id}
            data-slot="calendar-band"
            data-event-id={band.event.id}
            onPress={() => onActivate(band.event)}
            style={{
              top: 4 + band.lane * 22,
              left: `${left}%`,
              width: `calc(${width}% - 4px)`,
              marginLeft: 2,
            }}
            className={cn(
              "absolute flex h-5 cursor-pointer items-center gap-1.5 overflow-hidden px-2 text-left",
              "border-l-2 text-xs transition-colors duration-150",
              "outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
              palette.band,
              palette.edge,
              selectedId === band.event.id &&
                cn("outline-2 outline-solid outline-offset-0", palette.selected),
            )}
          >
            <span className="truncate font-medium text-quebi-fg">{band.event.title}</span>
          </Button>
        )
      })}

      {overflow ? (
        <div className="grid" style={columns}>
          {days.map((day, index) => (
            <div key={day.toString()} className="px-1">
              {(hiddenPerDay[index] ?? 0) > 0 ? (
                // Supplying `onMoreClick` says the consumer owns what "+N more"
                // does — the documented "switch to this day" hook — so the
                // library draws no panel behind it. Without it the panel is the
                // default, which is the only reading under which the affordance
                // does something on its own.
                onMoreClick ? (
                  <MoreLink
                    count={hiddenPerDay[index] ?? 0}
                    label={moreLabel}
                    onPress={() => onMoreClick(day, eventsOnDay(allBands, segments, index))}
                  />
                ) : (
                  <MoreLink count={hiddenPerDay[index] ?? 0} label={moreLabel}>
                    <DayOverflowPanel
                      day={day}
                      events={eventsOnDay(allBands, segments, index)}
                      calendars={calendars}
                      locale={locale}
                      timeZone={timeZone}
                      selectedId={selectedId}
                      onActivate={onActivate}
                    />
                  </MoreLink>
                )
              ) : null}
            </div>
          ))}
        </div>
      ) : null}

    </div>
  )
}

/**
 * `plain` is a bare row of dots for above or below the grid; `overlay` is the
 * same row on a surface, for a legend that sits *on* the calendar.
 */
export type CalendarLegendVariant = "plain" | "overlay"

export interface CalendarLegendProps {
  calendars: readonly CalendarSource[]
  /** Default "plain". "overlay" adds the surface an overlapping legend needs. */
  variant?: CalendarLegendVariant
  /**
   * The calendars currently shown. Pass it with `onChange` to make the legend
   * a filter; leave both out and it stays the read-only key it has always been.
   */
  value?: readonly string[]
  /** Called with the ids still shown. Its presence is what makes the row a filter. */
  onChange?: (ids: string[]) => void
  /** Accessible name for the filter group — the place to translate it. */
  "aria-label"?: string
  className?: string
}

const LEGEND_VARIANTS: Record<CalendarLegendVariant, string> = {
  plain: "",
  // The chart tooltip's treatment, for the same reason: a legend laid over the
  // grid floats above data, so it takes the floating surface — elevated ground,
  // hairline edge, the small radius and the one shadow the system has.
  overlay:
    "rounded-quebi-s border border-quebi-hairline bg-quebi-elevated px-2.5 py-1.5 shadow-quebi-float",
}

/**
 * The key: one dot and one name per calendar.
 *
 * Two or more series always get a legend — that is the rule that keeps the
 * palette treatment a second channel rather than the only one — and it lives in the library because
 * the palette does. An app that hand-rolled this row would be writing the series
 * colours into its own markup, which is the thing `no-hardcoded-design-values`
 * exists to stop; `CalendarTimeline` needs no legend because every row is
 * already labelled with its calendar's name.
 *
 * ## Where it goes is yours; what it looks like is not
 *
 * The legend takes no placement prop, because placement is layout: put it
 * before the view or after it, align it with `self-end`, stack it into a column
 * beside the grid with `className="flex-col items-start"`, or position it over
 * the calendar from a `relative` wrapper. All of that is one className and none
 * of it needs the library's permission.
 *
 * What the library does owe you is the one thing a placement cannot supply. A
 * legend laid over the grid has events behind it, so a bare row of small muted
 * text stops being readable — that is `variant="overlay"`, which is the same
 * row on an elevated surface. Reach for it whenever the legend overlaps
 * something, and leave it alone when the legend has a line of its own.
 *
 * ## A list of calendars implies you can switch one off
 *
 * Google and Outlook both make theirs a set of checkboxes, and a row reading
 * "My calendar · Team" invites the press whether or not it takes one — so
 * `value` + `onChange` turn the same row into that filter, with the dot kept in
 * front of the name because the dot is what the grid is drawn in (task #214).
 * The read-only key is still the default: a legend beside a calendar nobody can
 * filter should not look like one control with two answers.
 */
export function CalendarLegend({
  calendars,
  variant = "plain",
  value,
  onChange,
  "aria-label": ariaLabel = "Calendars",
  className,
}: CalendarLegendProps) {
  const dot = (calendar: CalendarSource) => (
    <span
      className={cn("size-2 shrink-0 rounded-full", CALENDAR_COLORS[calendar.color].dot)}
      aria-hidden="true"
    />
  )

  if (onChange) {
    return (
      <CheckboxGroup
        data-slot="calendar-legend"
        data-variant={variant}
        aria-label={ariaLabel}
        value={value ? [...value] : undefined}
        onChange={onChange}
        className={cn(
          "flex-row flex-wrap items-center gap-x-4 gap-y-1",
          LEGEND_VARIANTS[variant],
          className,
        )}
      >
        {calendars.map((calendar) => (
          <Checkbox
            key={calendar.id}
            value={calendar.id}
            className="gap-2 text-quebi-fg-muted text-xs"
          >
            {dot(calendar)}
            {calendar.name}
          </Checkbox>
        ))}
      </CheckboxGroup>
    )
  }

  return (
    <div
      data-slot="calendar-legend"
      data-variant={variant}
      className={cn(
        "flex flex-wrap items-center gap-x-4 gap-y-1",
        LEGEND_VARIANTS[variant],
        className,
      )}
    >
      {calendars.map((calendar) => (
        <span key={calendar.id} className="flex items-center gap-1.5 text-quebi-fg-muted text-xs">
          {dot(calendar)}
          {calendar.name}
        </span>
      ))}
    </div>
  )
}

export interface CalendarEventRowProps<E extends CalendarEvent = CalendarEvent> {
  event: E
  calendars: readonly CalendarSource[] | undefined
  locale: string
  timeZone: string
  isSelected: boolean
  onActivate: (event: E) => void
  /** Geometry — where the row sits and which of its corners are cut. */
  className?: string
  style?: React.CSSProperties
  /** `data-slot`; the month grid's chips answer to `calendar-chip`. */
  slot?: string
  /** `aria-describedby` — the move hint, on a row that can be dragged. */
  describedBy?: string
  /**
   * Draw the row as the ghost at a drag's target rather than as the event.
   *
   * Same chip, dashed in the focus ink, and inert: not a button, not in the
   * tab order, and hidden from the accessibility tree, because the drop is
   * announced in words by the view's live region and one ghost per frame is
   * not. It is here rather than in `MonthView` for the reason the rest of this
   * component is: a ghost that drew the dot, the time and the title itself
   * would be a second answer to "what does an event look like".
   */
  isPreview?: boolean
}

/**
 * One event on one line: a dot, a time and a title.
 *
 * A timed event is a dot, a time and a title on a transparent chip — the shape
 * that reads as "at 09:00" rather than as "all morning". An all-day or multi-day
 * one is the filled band, because it genuinely occupies the days it covers.
 *
 * Two things draw an event this way and they disagree about nothing except
 * where it goes: the month grid positions it absolutely from its band geometry,
 * and a "+N more" panel stacks it in a static list. So position is the caller's
 * (`className` and `style`) and everything that makes it *an event* — the
 * palette, the dot, the time, the selected ring — is here, once. Reusing the
 * chip as-is was the alternative, and it would have meant a list of absolutely
 * positioned rows all sitting on top of each other.
 */
export function CalendarEventRow<E extends CalendarEvent>({
  event,
  calendars,
  locale,
  timeZone,
  isSelected,
  onActivate,
  className,
  style,
  slot = "calendar-chip",
  describedBy,
  isPreview,
}: CalendarEventRowProps<E>) {
  const palette = CALENDAR_COLORS[resolveEventColor(event, calendars)]
  const filled = isAllDayEvent(event)

  const classes = cn(
    "flex h-5 cursor-pointer items-center gap-1.5 overflow-hidden px-1.5 text-left text-xs",
    "transition-colors duration-150",
    "outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
    filled ? cn(palette.band, "border-l-2", palette.edge) : "hover:bg-quebi-raised",
    // Selection is an outline, not the inset ring focus uses: it sits
    // outside the border box, so it neither overpaints `edge` nor vanishes
    // when the same row takes focus. `outline-solid` is load-bearing — it
    // is what displaces the `outline-none` above, which would otherwise
    // leave the outline styled away.
    isSelected && cn("outline-2 outline-solid outline-offset-0", palette.selected),
    className,
    // Last, and after `className`, because the ghost's dashed edge is the one
    // thing about it the caller does not get to place: it is what says this is
    // a picture of a drop and not an event.
    isPreview &&
      cn(
        "pointer-events-none z-10 outline-2 outline-quebi-focus outline-dashed",
        !filled && "bg-quebi-bg",
      ),
  )

  const content = (
    <>
      {filled ? null : (
        <span className={cn("size-1.5 shrink-0 rounded-full", palette.dot)} aria-hidden="true" />
      )}
      {filled ? null : (
        <span className="shrink-0 text-quebi-fg-subtle tabular-nums">
          {formatEventTime(event.start, locale, timeZone)}
        </span>
      )}
      <span className="truncate font-medium text-quebi-fg">{event.title}</span>
    </>
  )

  if (isPreview) {
    return (
      <div data-slot={slot} data-event-id={event.id} aria-hidden="true" style={style} className={classes}>
        {content}
      </div>
    )
  }

  return (
    <Button
      data-slot={slot}
      data-event-id={event.id}
      aria-describedby={describedBy}
      onPress={() => onActivate(event)}
      style={style}
      className={classes}
    >
      {content}
    </Button>
  )
}

export interface DayOverflowPanelProps<E extends CalendarEvent = CalendarEvent> {
  day: CalendarDate
  events: readonly E[]
  calendars: readonly CalendarSource[] | undefined
  locale: string
  timeZone: string
  selectedId: string | null
  onActivate: (event: E) => void
}

/**
 * What a "+N more" opens onto: the date, and everything on it.
 *
 * Everything, not only what the cell hid — a list that started at the fourth
 * event would be a list of what the layout happened to run out of room for, and
 * it is the same payload `onMoreClick` has always been handed. Activating a row
 * closes the panel, because the question it was opened to ask has been answered
 * and the answer is behind it.
 */
export function DayOverflowPanel<E extends CalendarEvent>({
  day,
  events,
  calendars,
  locale,
  timeZone,
  selectedId,
  onActivate,
}: DayOverflowPanelProps<E>) {
  return (
    <Dialog data-slot="calendar-day-panel" className="flex flex-col gap-1 p-2 outline-none">
      {({ close }) => (
        <>
          <Heading slot="title" className="px-1.5 pb-1 font-medium text-quebi-fg text-xs">
            {getDateTimeFormat(locale, {
              weekday: "long",
              day: "numeric",
              month: "long",
              timeZone,
            }).format(dayToDate(day, timeZone))}
          </Heading>
          {events.map((event) => (
            <CalendarEventRow
              key={event.id}
              event={event}
              calendars={calendars}
              locale={locale}
              timeZone={timeZone}
              isSelected={selectedId === event.id}
              onActivate={(activated) => {
                close()
                onActivate(activated)
              }}
              className="w-full"
              slot="calendar-day-event"
            />
          ))}
        </>
      )}
    </Dialog>
  )
}

export interface MoreLinkProps {
  count: number
  label: (count: number) => string
  /** Fires on press. Only reachable when the link opens no panel of its own. */
  onPress?: () => void
  /**
   * The panel the link opens, if it opens one.
   *
   * Given children the link is a popover trigger, and react-aria supplies
   * `aria-expanded`, focus into the panel, focus contained while it is open,
   * Escape to dismiss and focus back on the trigger on the way out — none of
   * which a bare button can claim. (It sets no `aria-haspopup`: react-aria
   * omits it for a dialog trigger, where `aria-expanded` is the signal.) Given
   * no children it stays that bare button, which is what keeps `onMoreClick`
   * the consumer's to own.
   */
  children?: React.ReactNode
}

/** The "+N more" affordance, shared by the all-day band and the month cells. */
export function MoreLink({ count, label, onPress, children }: MoreLinkProps) {
  const trigger = (
    <Button
      data-slot="calendar-more"
      data-more-count={count}
      onPress={children ? undefined : () => onPress?.()}
      className={cn(
        "w-full cursor-pointer truncate px-1 text-left text-xs",
        "text-quebi-fg-subtle transition-colors duration-150",
        "hover:bg-quebi-raised hover:text-quebi-fg",
        "outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
      )}
    >
      {label(count)}
    </Button>
  )

  if (!children) return trigger

  return (
    <Popover>
      {trigger}
      {/* A cell is narrower than the day it holds, so `min-w-(--trigger-width)`
          alone would draw a panel the width of a "+3 more". The cap is the
          popover's own `max-w-xs`, and `maxHeight` hands react-aria the ceiling
          it then shrinks further against the viewport — a busy day scrolls in
          the popover's `quebi-scrollbar` rather than running off the screen. */}
      <PopoverContent data-slot="calendar-more-panel" className="min-w-56" maxHeight={320}>
        {children}
      </PopoverContent>
    </Popover>
  )
}

/**
 * Everything a grid view forwards to the shell untouched.
 *
 * `DayView` and `WeekView` differ in which days they hand over and in nothing
 * else, so the props they merely pass through are named once here rather than
 * re-declared twice and drifting apart on the third change.
 */
export interface CalendarGridViewProps<E extends CalendarEvent = CalendarEvent>
  extends Pick<
    CalendarShellProps<E>,
    | "events"
    | "calendars"
    | "timeZone"
    | "locale"
    | "startHour"
    | "endHour"
    | "hourHeight"
    | "slotMinutes"
    | "axisWidth"
    | "height"
    | "now"
    | "showAllDayRow"
    | "maxAllDayLanes"
    | "allDayLabel"
    | "moreLabel"
    | "selectedEventId"
    | "defaultSelectedEventId"
    | "onSelectionChange"
    | "onEventClick"
    | "onMoreClick"
    | "isEventEditable"
    | "onEventChange"
    | "moveHintLabel"
    | "moveAnnouncement"
    | "className"
  > {}

export type { CalendarColorName, CalendarEvent, CalendarSource }
export { MINUTES_PER_DAY }