Skip to content

feat(scheduler): add scheduler state machine#3087

Merged
segunadebayo merged 55 commits intochakra-ui:v2from
Adebesin-Cell:feat/scheduler
Apr 23, 2026
Merged

feat(scheduler): add scheduler state machine#3087
segunadebayo merged 55 commits intochakra-ui:v2from
Adebesin-Cell:feat/scheduler

Conversation

@Adebesin-Cell
Copy link
Copy Markdown
Contributor

@Adebesin-Cell Adebesin-Cell commented Apr 17, 2026

Closes #

📝 Description

Adds @zag-js/scheduler — a framework-agnostic headless calendar machine with day / week / month / year / agenda views, drag-move / resize / click-to-create / double-click-to-create, native recurring events, conflict detection, RTL, and locale-aware formatting. 14 example pages under /scheduler/*.

⛳️ Current behavior (updates)

No scheduler primitive exists in the monorepo. Consumers building calendar UIs reach for external libraries, each of which ships opinionated markup and styling.

🚀 New behavior

Machine

Single createMachine with four states: idle | slot-selecting | event-dragging | event-resizing. All date math uses @internationalized/date. Drag via trackPointerMove from @zag-js/dom-query (no HTML5 DnD).

  • Perf: O(N log N) interval-graph layout via sweep-line, O(1) per-day / conflict / by-id lookups via pre-built Maps, RAF-coalesced pointermove, auto-scroll-on-drag near viewport edges. Stress-tested at 5000 events.
  • Native recurrence: recurrence: { freq, interval?, count?, until?, exdate? } expanded inside the machine; { rrule: string } still routes to user-supplied expandRecurrence for rrule-library users.
  • Typed payload: SchedulerEvent<T> threads through every callback detail. Single cast at useMachine(scheduler.machine as scheduler.Machine<MyPayload>, …)connect infers E from the service.

api surface (consumer renders markup; machine supplies everything else)

  • Data: today, visibleDays, visibleEvents, agendaGroups, visibleRange, visibleRangeText, weekDays, hourRange, events, dragPreview, dragOrigin, selectedSlot, monthNames, dir, prevTriggerIcon, nextTriggerIcon, todayTriggerLabel
  • Lookups: getEventById, getEventsForDay, getEventsForSlot, hasConflict, getEventState
  • Layout: getEventPosition, getEventStyle, getTimePercent, getMonthGrid, getMonthName
  • Drag overlays: getDragGhost({ date }), getDragOrigin({ date }), getSelectedSlot({ date })
  • Formatters: formatTime, formatTimeRange, formatLongDate, formatDuration
  • Navigation: setView, setDate, goToToday, goToNext, goToPrev, clearSelectedSlot
  • 18 prop-getters for every part in the anatomy
  • Statics: scheduler.getToday(timeZone?), scheduler.getDurationMinutes(start, end)

UX

  • Snappy drag: source event fades to 0.25 opacity, ghost at predicted drop position follows the cursor per-frame, dashed "was here" origin outline at the starting bounds, drop-target column highlight while dragging.
  • Click vs drag disambiguation: onSlotClick (plain click → highlight), onSlotDoubleClick (create-event trigger), onSlotSelect (drag selection).
  • Click-to-create example composes with @zag-js/dialog.
  • Event-details example composes with @zag-js/popover (anchored via getAnchorRect).

RTL + i18n

  • Every style in the grid uses logical properties (inset-inline-start/end, border-inline-start, padding-inline, etc.).
  • Direction-aware arrows via api.prevTriggerIcon / nextTriggerIcon.
  • unicode-bidi: plaintext on headers / titles, direction: ltr + isolate on numeric hour labels so Latin times stay readable inside RTL grids.
  • Toggle dir="rtl" on props — works across every example.

CSS variables emitted on root

--scheduler-visible-days, --scheduler-day-count, --scheduler-hour-count. Consumer-overridable: --scheduler-time-gutter-width (60px), --scheduler-hour-height (56px), --scheduler-grid-height, --scheduler-event-inset (2px), --scheduler-agenda-group-gap, --scheduler-aside-width, --scheduler-aside-gap.

Examples

basic, views, controlled, work-week, all-day, recurring, agenda, year-view, mobile-month-view, with-date-picker, payload, event-details, click-to-create, stress — 14 pages, ~2150 LOC total (basic.tsx is 122).

💣 Is this a breaking change (Yes/No):

No — net-new package.

📝 Additional Information

Deferred to follow-ups:

  • Timeline view (horizontal resource rows) — needs real use cases to shape the API.
  • Full RRULE expansion inside the machine — only common frequency patterns are native; rrule-string users supply their own library.
  • Work-week as a distinct ViewType — today it's view="week" + workWeekOnly: true.

Performance: the stress example generates 100–5000 events via a seeded PRNG and is drag-interactive without jank at 5000.

Composability: the click-to-create and event-details examples validate the "compose scheduler with another zag machine" story — @zag-js/dialog and @zag-js/popover respectively.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 17, 2026

⚠️ No Changeset found

Latest commit: 1e4f504

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zag-nextjs Ready Ready Preview Apr 22, 2026 8:49pm
zag-solid Ready Ready Preview Apr 22, 2026 8:49pm
zag-svelte Ready Ready Preview Apr 22, 2026 8:49pm
zag-vue Ready Ready Preview Apr 22, 2026 8:49pm
zag-website Ready Ready Preview Apr 22, 2026 8:49pm

Request Review

Adebesin-Cell and others added 29 commits April 22, 2026 10:01
The machine already supports `view: "agenda"` (30-day rolling window
from the focused date) and connect exposes the "Agenda" label, but
there was no example page and the shared control panel's view select
didn't offer it. Add an agenda page that groups events by day and
register it in the examples route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback copy said "next 30 days" even after navigating backwards,
which was wrong once the window no longer starts at today. Show the
real visibleRange bounds instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neither was hooked up to real behavior:

- Resource/resourceId/getResourceHeaderProps/resources prop threaded
  through every layer but no example used it and the drag flow never
  populated `newResourceId`. Removing the speculative API lets us
  re-introduce a proven shape once a real multi-resource use case
  (booking, Gantt) drives the requirements.
- `SchedulerEvent.display: "default" | "background"` was declared but
  neither machine nor connect ever read it — consumers couldn't rely
  on anything.

BREAKING: these types no longer exist — `Resource`,
`ResourceHeaderProps`, `resources` prop/api, `resourceId` on events
and details, `newResourceId` on drop details, and `display` on events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull locale-aware data and RTL-unsafe position math out of every
example and into the api:

- api.today — locale/timezone-aware today date
- api.visibleDays — enumerated dates in the visible range
- api.weekDays — 7 localized day-of-week labels ordered by weekStartDay
- api.hourRange — { start, end, hours } for the day/week grid
- api.visibleRangeText — { start, end, formatted } header text
- api.getEventById — O(1) Map lookup (replaces .find scans)
- api.getEventStyle — ready-to-spread CSS with inset-inline logical
  props; used by getEventProps internally
- api.getTimePercent — 0..1 fraction of the visible day range
- api.dir — writing direction, also emitted on root

Internal getEventProps now delegates to getEventStyle, and the
current-time indicator switched to inset-inline-start/end so RTL
locales render correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Year view was rebuilding the month grid, weekday initials and
today/outside data attrs by hand. Pull all of it into the api:

- api.getMonthGrid(date?) — weeks × days with inMonth/isToday/isWeekend
- api.getMonthName / api.monthNames — locale-aware month labels
- getDayCellProps now emits data-today, data-outside, data-weekend,
  data-selected, and aria-current="date" when applicable; accepts an
  optional referenceDate so mini-month cells know what "outside" means
- Root exposes --scheduler-visible-days, --scheduler-day-count, and
  --scheduler-hour-count so grid-template-columns can live in CSS
  instead of being recomputed inline per example

Also dedupe DAY_NAMES (token strings for @internationalized/date
startOfWeek, not display labels) — they already existed in
utils/visible-range.ts. Shared via a new toDayOfWeekToken export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- EventDropDetails and EventResizeDetails now carry `index` + a
  strongly-typed `apply(events)` so consumers can write
  `setEvents(d.apply)` instead of mapping by hand. The generic is
  constrained to `{ id; start; end }` — no `any` leaks.
- Grid height moves to CSS: --scheduler-hour-height (default 56px) and
  --scheduler-grid-height (computed from hour-count × hour-height) are
  applied on the time grid, time gutter, and day columns. basic.tsx no
  longer passes `style={{ height: gridHeight }}` on every element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consumers were computing hour positions by hand:
  style={{ top: `${((h - start) / (end - start)) * 100}%` }}
and formatting labels with padStart. Push both into the api.

HourEntry is now { value, label, percent } where label is
locale-formatted via Intl.DateTimeFormat and percent is the pre-computed
0..1 grid position. Consumers just do `style={{ top: `${h.percent * 100}%` }}`
and render `{h.label}`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Export `scheduler.getToday(timeZone?)` so examples and apps stop
  reaching into @internationalized/date to construct a starting date.
  Returns a CalendarDateTime at midnight in the given/local timezone.
- basic.tsx drops `defaultDate` entirely — the machine already defaults
  to `today(timeZone)` — and anchors demo events relative to
  `scheduler.getToday()` so the initial view always has visible content
  regardless of wall-clock date.
- HourEntry gains `style: { top }` so consumers stop doing
  `style={{ top: \`${percent * 100}%\` }}` in each example. Kept
  `percent` for custom layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a drag/resize/slot-select pushes the pointer within 50px of the
scheduler's scroll container edge, auto-scroll the viewport at up to
12px/frame with proximity ramping. Mirrors the Mantine UX so gestures
can reach events that are below the fold without letting go.

The scrollable ancestor is discovered once per gesture by walking up
from the grid element; if nothing scrolls, auto-scroll is a no-op.
Scroll RAF is stopped on pointer-up, escape, and effect teardown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a plain click on an empty slot fired onSlotSelect with
start === end, forcing consumers to detect "was this a click?"
themselves. Now the machine does the fork at invoke time:

- onSlotClick(details) fires when the gesture never crossed a snap
  boundary — i.e. the user clicked without dragging. Details carry
  start plus a convenience `end` (start + slotInterval) so "create
  event at this time" flows can one-line it.
- onSlotSelect(details) fires only when a real drag selection
  happened (start < end).

No callback wiring changes for consumers using onSlotSelect alone —
they'll just stop seeing zero-width selections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- utils/layout.ts replaces per-event overlap resolution with a
  single sweep-line pass. O(N log N) for the full set instead of
  O(N cubed).
- connect: computeEventLayout runs once; getEventPosition is a
  Map.get. getEventsForDay reads a pre-built per-day bucket. Slot
  lookups scan only that day's bucket. hasConflict / getEventState
  read a pre-built conflict Set. Multi-day events bucket into every
  day they touch.
- stress.tsx generates 100 to 5000 events via a seeded PRNG so perf
  can be eyeballed and drag/resize exercised at scale.
- Renamed recurrenceExpansionLimit to maxRecurrenceInstances.
- api.dragOrigin exposes the original bounds of the active gesture
  target so consumers can paint a faint origin outline.
- onSlotClick forked from onSlotSelect — clicks fire with a
  convenience end = start plus slotInterval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure mechanical refactor — no feature changes. Every scheduler example
except basic.tsx was still running the old hand-rolled patterns that
basic.tsx dropped:

- DAY_LABELS arrays          → api.weekDays
- enumerateDays util         → api.visibleDays
- HOURS Array.from           → api.hourRange.hours (with .style + .label)
- gridHeight + gridTemplateColumns inline → driven by CSS vars
- per-event top/height/left/width math → api.getEventStyle
- visibleRange.start.toString().slice(0,10) → api.visibleRangeText.formatted
- hand-rolled month grid + data-today/data-outside → api.getMonthGrid +
  getDayCellProps
- onEventDrop/Resize .map boilerplate → d.apply

Files touched and line-count delta:
  all-day.tsx          195 → 151  (-44)
  controlled.tsx       179 → 145  (-34)
  mobile-month-view.tsx 175 → 156 (-19)
  recurring.tsx        170 → 134  (-36)
  views.tsx            316 → 238  (-78)
  with-date-picker.tsx 258 → 213  (-45)
  work-week.tsx        161 → 125  (-36)

Total: 1454 → 1162 lines (~20% off).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…utline

Drag previously felt like "event teleports on release". Now mirrors
Mantine's feel:

- Source event fades to 0.25 opacity in place while [data-dragging] —
  the "was here" cue without tearing the element out.
- Root emits data-dragging / data-resizing / data-slot-selecting so
  other events stop intercepting pointer events during a gesture.
- Day columns emit data-drop-target when the live drag resolves to
  them. CSS paints a soft background + inline-start accent rule so
  the user sees where the drop will land before releasing.
- basic.tsx renders two overlays inside each day column: a filled
  .scheduler-drag-ghost that tracks the predicted drop position, and
  a dashed .scheduler-drag-origin outline at the gesture's starting
  bounds. Both read from api.dragPreview / api.dragOrigin — no
  state plumbing in user code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consumers were still rebuilding the ghost and origin-outline styles
by hand: top/height percentages, inset-inline 2px padding, and the
--event-color CSS var. Push all of that into the api:

- getDragGhost({ date }) → { style, event } | null
- getDragOrigin({ date }) → { style, event } | null

Both return null when nothing should render in the given column so
consumers just do `ghost && <div {...}>`. Style is an EventStyle with
logical props and --event-color baked in. getEventStyle also now
includes --event-color so callers don't spread it manually.

basic.tsx drops ~40 lines of positional math and extraneous imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2px inline padding on .scheduler-drag-ghost and .scheduler-drag-origin
was being emitted inline from the api on every pointer-move, which
mixed presentation into the machine's responsibilities. It now lives
in CSS via --scheduler-event-inset (default 2px), overridable from
the consumer's stylesheet. getDragGhost / getDragOrigin return only
positioning (top/height) and --event-color.

EventStyle's insetInlineStart/End are now optional since ghost/origin
omit them entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e icons

Every example now flips cleanly when dir="rtl" without a separate
rtl demo page — just toggle the control panel. Instead of a standalone
example we wire rtl into schedulerControls.dir.

- Shared CSS swept from physical to logical properties everywhere
  basic.tsx touches: border-left/right → border-inline-start/end,
  left/right on absolute children → inset-inline-start/end,
  margin-left → margin-inline-start, padding-left/right → padding-inline,
  border-radius corners on the resize handle → border-start/end-*.
- api exposes direction-aware prev/next glyphs:
    api.prevTriggerIcon  // "←" in LTR, "→" in RTL
    api.nextTriggerIcon  // "→" in LTR, "←" in RTL
  basic.tsx consumes those; the hardcoded arrows are gone.
- Dropped the planned rtl.tsx example route — the control handles it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
"7:00 AM" was rendering as "AM 7:00" when the grid is RTL because the
unicode bidi algorithm reordered the two weakly-LTR runs. Add
direction: ltr + unicode-bidi: isolate to .scheduler-hour-label so
the formatter output renders as a single LTR atom regardless of the
surrounding direction.

Header title gets unicode-bidi: plaintext so it auto-picks direction
from content — English formatter output stays LTR, Arabic locale
output stays RTL, no container reordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eview" in rtl

text-overflow clips from the inline-end side, which for LTR English
content inside an RTL grid is the physical left — producing "...sign
review" instead of "Design review..." Added unicode-bidi: plaintext
to .scheduler-event-title so the Latin run picks its own direction
and truncates from its own end.

Same fix was already applied to .scheduler-hour-label and the header
title; this rolls it out to event titles too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o-create, type payload cleanly

Comment cleanup across scheduler source — dropped running commentary,
kept only the one-liner explaining the +1 day range-filter extension
(the single non-obvious why). Types / machine / connect / layout /
visible-range all slimmer.

Over-emphatic selected state: shadow was 2px ring + drop shadow.
Dropped to a 1px ring so clicking to select doesn't swamp the card.

Click-to-create: basic.tsx now wires onSlotClick to prompt for a
title and insert the event. Confirms the onSlotClick vs onSlotSelect
fork fires correctly on a plain click.

Payload example: rewritten to show title + compact meta line in the
card, full payload in a side panel on click. Latin padStart time
format replaced with Intl.DateTimeFormat.

Typed payload without any/as-unknown: added SchedulerPayload type
alias (= any) mirroring collection's CollectionItem pattern — now
every generic (SchedulerEvent, SchedulerProps, SchedulerSchema,
SchedulerApi, connect's E, layout utils) uses
`T extends SchedulerPayload = SchedulerPayload`. Consumers specialize
with a single cast at the useMachine call site:

  useMachine(scheduler.machine as scheduler.Machine<MyPayload>, props)

The connect return is inferred from the service type — no second cast.
Matches the select / combobox / listbox / gridlist / tree-view pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
basic.tsx wired onSlotClick but nothing fired because day columns had
no pointerdown handler — only getTimeSlotProps (unused here) and
getDayCellProps (month view) did. Added pointerdown to
getDayColumnProps that derives start/end from the click Y position
using the configured slotInterval, ignoring clicks that land on an
existing event.

Result: clicking on empty space inside a day column fires
SLOT_POINTER_DOWN, which on release (no drag) fires onSlotClick
with the snapped slot bounds — the promised click-to-create flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mple

A plain slot click now lights up the clicked slot immediately, so
users get visual confirmation before any create flow opens:

- Machine sets context.selectedSlot on click / drag-select; clears on
  escape or a fresh SLOT_POINTER_DOWN.
- api adds: selectedSlot (readonly), clearSelectedSlot(), and
  getSelectedSlot({ date }) returning { style, start, end } or null —
  same shape as getDragGhost / getDragOrigin.
- New .scheduler-slot-selected CSS (dashed outline + soft fill) reads
  the shared --scheduler-event-inset var.

basic.tsx drops the blocking window.prompt and just renders the
highlight — that's enough to demonstrate the UX primitive.

New example click-to-create.tsx shows the proper create flow built
on @zag-js/dialog: on onSlotClick open a dialog prefilled with the
slot, Enter / Create inserts the event, Escape / Cancel clears the
highlight via api.clearSelectedSlot. Dialog styles added to shared
scheduler.css (backdrop, positioner, content, primary button).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same treatment as the .ts files — section headers and running
commentary removed. Selectors are self-documenting.
Matches google/apple calendar UX: single click highlights the slot,
double click opens the create flow. Previously the two were conflated
on onSlotClick, so users could never just select a slot without
triggering create.

- New onSlotDoubleClick callback fires from a dblclick handler on
  getDayColumnProps (time-grid views) and getDayCellProps (month
  view). Slot math reuses the same helper as the pointerdown handler.
- onSlotClick still fires for the highlight path — basic.tsx's slot
  highlight still shows on single click.
- click-to-create.tsx now opens the dialog on onSlotDoubleClick.
  Route relabeled to "Double-click to Create (Dialog)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… workWeekOnly

Two subagents in parallel landed most of this:

Machine additions:
- api.visibleEvents — events filtered to visibleRange (already
  computed internally, now exposed).
- api.agendaGroups — [{ date, events }] grouped by day and sorted.
  Kills hand-rolled groupByDay/bucketing in every agenda-style page.
- api.formatTime / formatTimeRange / formatLongDate — Intl-backed
  helpers; replaces padStart + toLocaleDateString scattered in 5 files.
- api.todayTriggerLabel — pulled from translations; consumers stop
  hardcoding "Today".
- workWeekOnly prop: when true + view="week", api.visibleDays filters
  to workWeekDays. work-week.tsx can drop its manual .getDay() sieve.

Example sweep (13 pages touched):
- ←/→ → api.prevTriggerIcon / api.nextTriggerIcon (10 pages)
- events.find(e=>e.id===id) → api.getEventById (event-details, payload)
- views.tsx dropped its ~22-line drag-ghost reimplementation in favor
  of api.getDragGhost.
- "Today" hardcode → {api.todayTriggerLabel} (13 pages).
- local formatTime / formatLongDate / padStart deleted from agenda,
  event-details, mobile-month-view, payload. All use api.formatTime*
  / formatLongDate.
- agenda.tsx: groupByDay + the visible-range filter gone — uses
  api.agendaGroups + api.visibleRangeText.formatted. Inline
  marginBottom moved to a .scheduler-agenda-group class backed by
  --scheduler-agenda-group-gap.
- agenda.tsx events anchored to scheduler.getToday() instead of
  hardcoded 2026 CalendarDateTime constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
payload.tsx was ~30 lines of inline style objects on the event detail
aside and grid container. Moved everything to shared CSS classes
backed by CSS vars where sensible:

- .scheduler-with-aside (grid container) with --scheduler-aside-width
  and --scheduler-aside-gap
- .scheduler-event-panel + sub-classes for title / time / sections /
  row / link / empty

mobile-month-view's inline `new Date(...).toLocaleDateString({ month,
year })` swapped for `api.getMonthName(api.date) + api.date.year` —
uses the existing machine formatter instead of constructing a JS Date
just for localization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…urationMinutes

Two leftover spots the earlier audit flagged:

1. work-week.tsx was still hand-filtering api.visibleDays via
   `new Date(d.year, d.month-1, d.day).getDay()` — the exact pattern
   `workWeekOnly` was introduced to replace. Flip `workWeekOnly: true`
   and destructure `visibleDays` directly. Dropped the local
   WORK_WEEK const.

2. recurring.tsx's weeklyExpander computed event duration by round-
   tripping DateValue → string → JS Date → getTime() three times per
   instance — fragile (timezone-dependent via toString) and noisy.
   Exposed `scheduler.getDurationMinutes(start, end)` from utils and
   used it; the expander is now 6 lines instead of 20 and doesn't
   touch JS Date at all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommon rules

Structured recurrence the machine expands itself:

  recurrence: { freq: "daily" | "weekly" | "monthly" | "yearly";
                interval?: number; count?: number; until?: DateValue;
                exdate?: DateValue[] }

The old { rrule: string } variant still works and still routes through
the user's expandRecurrence prop — opt-in for rrule-string users.

connect's expansion pipeline:
- rec has `freq`  → expandNativeRecurrence (built-in sweep)
- rec has `rrule` → delegate to expandRecurrence
- no rec          → pass through

recurring.tsx drops its 13-line weeklyExpander and its
`expandRecurrence` prop. Events just declare
`recurrence: { freq: "weekly" }` or `{ freq: "weekly", interval: 2,
count: 8 }` and the machine produces the instances, filtered to
visibleRange and capped by maxRecurrenceInstances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final cleanup pass:

- api.formatDuration(start, end) returns "1h 30m" / "45m" etc. Uses
  getDurationMinutes internally. event-details.tsx drops its 20-line
  toMinutes / formatDuration helpers and uses the api method.
- event-details popover: 6 inline style objects moved to shared CSS —
  .scheduler-event-popover + body/row/dot/title/time/duration/actions
  classes. --event-color scoped via inline style on the body wrapper,
  consumed by .scheduler-event-popover-dot via var().
- agenda.tsx drops its lone `style={{ marginTop: 12 }}` override
  (the class already has margin-top: 16px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anatomy now covers the overlays / hour marks / agenda groups that
consumers render today:

  dragGhost, dragOrigin, slotHighlight, hourLabel, hourLine,
  agendaGroup, agendaGroupTitle

getDragGhost / getDragOrigin / getSelectedSlot now return
`{ props, event? }` where props spreads the anatomy data-attr +
positioning style. Consumers spread instead of wiring a className:

  {ghost && <div {...ghost.props}>{ghost.event.title}</div>}

CSS selectors for the overlays switched from `.scheduler-drag-ghost`
class to `[data-scheduler-drag-ghost]` attribute so users styling
via their own classnames aren't blocked.

README rewritten against the current api surface — install, quick
start, event model, views, props table, callbacks table, full api
reference grouped by purpose, recurrence (native + rrule fallback),
styling via CSS vars, RTL, perf guarantees, controlled vs uncontrolled,
composition examples, anatomy parts list. No invented symbols — every
referenced name exists in scheduler.types.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants