{"id":51294266,"url":"https://github.com/schappim/stimulus_calendar","last_synced_at":"2026-06-30T13:01:26.929Z","repository":{"id":360610767,"uuid":"1248677528","full_name":"schappim/stimulus_calendar","owner":"schappim","description":"Full-sized drag \u0026 drop event calendar for Stimulus.js (Hotwire) with live multi-user sync over Turbo Streams. 100% Stimulus port of vkurko/calendar.","archived":false,"fork":false,"pushed_at":"2026-05-27T07:04:22.000Z","size":3122,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T07:09:52.914Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/schappim.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-25T00:10:14.000Z","updated_at":"2026-05-27T07:04:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/schappim/stimulus_calendar","commit_stats":null,"previous_names":["schappim/stimulus_calendar"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/schappim/stimulus_calendar","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_calendar","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_calendar/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_calendar/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_calendar/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/schappim","download_url":"https://codeload.github.com/schappim/stimulus_calendar/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schappim%2Fstimulus_calendar/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34967627,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-30T02:00:05.919Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-30T13:01:25.302Z","updated_at":"2026-06-30T13:01:26.854Z","avatar_url":"https://github.com/schappim.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# stimulus_calendar\n\n[![CI](https://github.com/schappim/stimulus_calendar/actions/workflows/ci.yml/badge.svg)](https://github.com/schappim/stimulus_calendar/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/@ninjaai/stimulus_calendar?label=npm)](https://www.npmjs.com/package/@ninjaai/stimulus_calendar)\n[![stimulus_calendar_rails gem](https://img.shields.io/gem/v/stimulus_calendar_rails?label=stimulus_calendar_rails)](https://rubygems.org/gems/stimulus_calendar_rails)\n\nA full-sized, **HTML-first event calendar for [Stimulus.js](https://stimulus.hotwired.dev/) (Hotwire)** — month, week, day, list, resource and timeline views, drag \u0026amp; drop, resource scheduling, and **live multi-user sync over Turbo Streams**. Drop `data-controller=\"calendar\"` on a `\u003cdiv\u003e`, describe the calendar with `data-*` attributes, and you get a working calendar — no React, no build-time options object, no third-party scheduling framework. With the optional [`stimulus_calendar_rails`](gem/stimulus_calendar_rails) companion, every drag, resize or edit also **streams live to every connected client over Turbo Streams** (Action Cable) — optimistic updates, server-side validation, and tenant-scoped broadcasts included.\n\nA 100% Stimulus port of [vkurko/calendar](https://github.com/vkurko/calendar) (Svelte 5; v5.7.1). Inspired by [FullCalendar](https://fullcalendar.io/).\n\n![stimulus_calendar — month view with multi-day events spanning weeks, today highlighted, week numbers in the gutter](docs/images/cal-overview.png)\n\n\u003e Prefer the Rails/Hotwire server-driven version — live multi-user editing\n\u003e over Turbo Streams, server-side event sources, optimistic updates, and\n\u003e drag/drop persistence? It ships as the **`stimulus_calendar_rails`** gem;\n\u003e see the **Rails \u0026 Hotwire** section below,\n\u003e [`gem/stimulus_calendar_rails`](gem/stimulus_calendar_rails), and\n\u003e [`RAILS.md`](RAILS.md). LLM usage docs live in [`skills/`](skills).\n\n## Status\n\n🚧 **Early — migration in progress.** See [PLAN.md](./PLAN.md) for the\nper-feature checklist. Each unchecked box is a planned commit, shipped with\ntests, a demo, a screenshot, and (where Rails-touching) a matching\n`gem/demo/test/` case run against a real Rails app.\n\n---\n\n## Install\n\n**Option A — plain `\u003cscript\u003e` (no bundler).** Self-contained IIFE bundle with\nStimulus included; works over `file://`, a static server, anything. Vendor the\nfiles from `dist/`, or load them from a CDN:\n\n```html\n\u003clink rel=\"stylesheet\" href=\"https://unpkg.com/@ninjaai/stimulus_calendar/dist/stimulus_calendar.css\" /\u003e\n\u003cscript src=\"https://unpkg.com/@ninjaai/stimulus_calendar/dist/stimulus_calendar.js\"\u003e\u003c/script\u003e\n\u003cscript\u003e StimulusCalendar.start() \u003c/script\u003e\n```\n\n**Option B — npm + a bundler (Vite, esbuild, webpack…).** Stimulus is a peer\ndependency, so install it alongside:\n\n```bash\nnpm install @ninjaai/stimulus_calendar @hotwired/stimulus\n```\n\n```js\nimport { Application } from \"@hotwired/stimulus\"\nimport StimulusCalendar from \"@ninjaai/stimulus_calendar\"   // resolves to dist/stimulus_calendar.esm.js\nimport \"@ninjaai/stimulus_calendar/style.css\"\n\nconst app = Application.start()\nStimulusCalendar.start(app)                  // registers calendar (+ later: header-toolbar, day-cell, …)\n```\n\n`StimulusCalendar.start(app?)` registers all controllers on the given Stimulus\n`Application` (or starts a new one) and returns it.\n\n**Option C — Rails / Hotwire (gem from RubyGems).** The\n[`stimulus_calendar_rails`](https://rubygems.org/gems/stimulus_calendar_rails)\ngem bundles this calendar *and* the live-sync layer, importmap-pinned — no JS\nbuild, no `dist/` to vendor:\n\n```bash\nbundle add stimulus_calendar_rails\n```\n\nFull setup (importmap, stylesheet, routes, optional migration) is in the\n**Rails \u0026 Hotwire** section below.\n\n**Option D — clone the repo and run it locally.** Useful for following along\nwith the port or sending a PR:\n\n```bash\ngit clone git@github.com:schappim/stimulus_calendar.git\ncd stimulus_calendar\nnpm install\nnpm run dev                     # open http://localhost:5173/demo/\nnpm test                        # JS test suite (Vitest)\nnpm run build:lib               # build dist/stimulus_calendar.js + .esm.js + .css\n\n# Rails companion (once Phase 14 has shipped):\ncd gem/demo\nbundle install\nbin/rails db:setup\nbin/rails server                # open http://localhost:3000/calendars\nbin/rails test                  # Rails integration tests\n```\n\n## Quick start\n\n```html\n\u003clink rel=\"stylesheet\" href=\"dist/stimulus_calendar.css\" /\u003e\n\n\u003cdiv data-controller=\"calendar\"\n     data-calendar-view-value=\"timeGridWeek\"\n     data-calendar-plugins-value='[\"TimeGrid\", \"Interaction\"]'\n     data-calendar-options-value='{\n       \"events\": [\n         { \"id\": \"1\", \"title\": \"Standup\",     \"start\": \"2026-05-25T09:00\", \"end\": \"2026-05-25T09:30\" },\n         { \"id\": \"2\", \"title\": \"Design crit\", \"start\": \"2026-05-26T14:00\", \"end\": \"2026-05-26T15:00\" }\n       ]\n     }'\n     style=\"height: 600px\"\u003e\u003c/div\u003e\n\n\u003cscript src=\"dist/stimulus_calendar.js\"\u003e\u003c/script\u003e\n\u003cscript\u003eStimulusCalendar.start()\u003c/script\u003e\n```\n\nEvents can be **server-rendered** (passed via `data-calendar-options-value` JSON),\nloaded from a **URL** (`data-calendar-event-source-value=\"/events.json\"`), or\nmutated in JS via `element.calendarApi.addEvent({…})`.\n\n## Screenshots\n\n\u003e Images land here as each feature ships — see [PLAN.md](./PLAN.md). The\n\u003e filenames below are the canonical targets; the dev server + Rails dummy\n\u003e app are what they're captured from.\n\n**Month view** — multi-day events span weeks, today is highlighted, week\nnumbers in the left gutter.\n\n![dayGridMonth view with multi-day events spanning weeks and week numbers in the gutter](docs/images/cal-month.png)\n\n**Week view (TimeGrid)** — sidebar of time slots + day columns, all-day row\nacross the top, now indicator drawn live.\n\n![timeGridWeek view with hourly slots, an all-day row, and an overlapping morning meeting](docs/images/cal-week.png)\n\n**List view** — chronological list of events grouped by day.\n\n![listWeek view: chronological event list with day headers and event time + title rows](docs/images/cal-list-week.png)\n\n**Resource timeline** — vertical resource axis + horizontal time axis, with\nnested expandable resources.\n\n![resourceTimelineWeek view with three top-level resources, one expanded to show two child resources, events laid out on each row](docs/images/cal-resource-timeline-week.png)\n\n**Drag, drop \u0026amp; resize** — pointer-driven edits. With the Rails gem, each\ndrag PATCHes the server and broadcasts the new times to every other tab.\n\n![Event being dragged in timeGridWeek with a ghost preview and the originating cell dimmed](docs/images/cal-drag.png)\n\n**Live multi-user sync** — Turbo Streams: one user moves an event, every other\nconnected user sees it move within ~50ms.\n\n![Animated GIF: two browser windows side-by-side showing the same calendar; an event moves on the left and the right window updates within ~50\u0026nbsp;ms](docs/images/cal-broadcast.gif)\n\n## Event popover (double-click)\n\nDouble-click any event chip — in any view — and the built-in popover\nopens next to it with title, time range, description, and any\n`extendedProps`. `Edit` / `Delete` fire `calendar:eventPopoverEdit` /\n`calendar:eventPopoverDelete` for your host app to wire up its own\nmodal flow. See [`demo/16-event-popover.html`](demo/16-event-popover.html).\n\n![timeGridWeek with the Design crit event highlighted in red and a floating popover next to it titled \"Design crit\" containing the time \"Tue, May 26 · 2:00 PM – 3:30 PM\", the description \"Review the new layout proposals from the brand team\", a key/value list (Category: design, Location: Studio B, Attendees: Alex, Sam, Marcus), and Edit + Delete buttons in the footer](docs/images/cal-event-popover.png)\n\n```js\n// Programmatic\nel.calendarApi.openEventPopover('event-id')\nel.calendarApi.closeEventPopover()\n\n// Suppress the default popover for one chip\nel.addEventListener('calendar:eventDoubleClick', (e) =\u003e {\n  if (e.detail.event.id === 'special') e.preventDefault()\n})\n\n// …or disable globally\nel.calendarApi.setOption('suppressEventPopover', true)\n\n// React to popover actions\nel.addEventListener('calendar:eventPopoverEdit',   (e) =\u003e openMyEditModal(e.detail.event))\nel.addEventListener('calendar:eventPopoverDelete', (e) =\u003e confirmDelete(e.detail.event))\n```\n\nWorks in DayGrid, TimeGrid, List, ResourceTimeGrid, and ResourceTimeline —\nevery view dispatches `calendar:eventDoubleClick` with `{ event, jsEvent,\nview, el, resource? }`.\n\n## Filtering, search \u0026 nested resources\n\nThe same `eventFilter` hook backs every \"show me only X\" interaction.\nFor nested data, ResourceTimeline takes a tree of resources via\n`children:` and renders them with collapse/expand chevrons.\n\n**Category filters** — toggle a category to update `options.eventFilter`;\nthe recompute pipeline redraws visible chips in place. See\n[`demo/13-filters.html`](demo/13-filters.html).\n\n![timeGridWeek with four category chips at the top (Engineering, Design, Marketing on; Ops off) — events in the off category are hidden from the grid](docs/images/cal-filters.png)\n\n```js\nconst enabled = new Set(['engineering', 'design', 'marketing'])\nel.calendarApi.setOption('eventFilter',\n  ({ event }) =\u003e enabled.has(event.extendedProps?.category))\n```\n\n**Search** — funnel a text query into `eventFilter` to show only events\nwhose title matches; the visible-count badge stays in sync. See\n[`demo/14-search.html`](demo/14-search.html).\n\n![dayGridMonth with a search box reading \"design\" at the top, a \"3 / 10\" results badge, and only three events visible in the grid: Design crit, Design workshop, Brand redesign sync](docs/images/cal-search.png)\n\n```js\ninput.addEventListener('input', () =\u003e {\n  const needle = input.value.trim().toLowerCase()\n  el.calendarApi.setOption('eventFilter', needle\n    ? ({ event }) =\u003e (event.title || '').toLowerCase().includes(needle)\n    : undefined)\n})\n```\n\n**Nested resources** — pass a tree to `data-calendar-resources-value`\n(or `setOption('resources', […])`). Children sit under their parent in\nthe row sidebar; parents render a `+`/`−` chevron to collapse the\nsubtree. Events bind to leaf resources via `resourceIds`. See\n[`demo/15-nested-resources.html`](demo/15-nested-resources.html).\n\n![resourceTimelineWeek with a Studios parent containing Studio A (Vocal booth A1, Live room A2) and Studio B (Vocal booth B1); a Meeting rooms parent containing Boardroom and Phone booth; events placed against each leaf resource](docs/images/cal-nested-resources.png)\n\n```html\n\u003cdiv data-controller=\"calendar\"\n     data-calendar-plugins-value='[\"ResourceTimeline\"]'\n     data-calendar-resources-value='[\n       { \"id\":\"studios\", \"title\":\"Studios\", \"expanded\": true, \"children\": [\n           { \"id\":\"sa\", \"title\":\"Studio A\", \"children\": [\n               { \"id\":\"sa1\", \"title\":\"Vocal booth A1\" },\n               { \"id\":\"sa2\", \"title\":\"Live room A2\" }\n           ]}\n       ]}\n     ]'\u003e\u003c/div\u003e\n```\n\n## Calendar attributes (`data-calendar-*-value`)\n\nPass any option as a `data-calendar-\u003coption\u003e-value` attribute (kebab-case),\nor bundle several together under `data-calendar-options-value` as a JSON\nobject. Both forms are equivalent — pick whichever is convenient. Object /\narray values must be JSON-encoded; scalars stay literal.\n\nEvery option below is built in — there is **no external options reference\nto consult**. Items ship as their matching [PLAN.md](./PLAN.md) checkbox\nticks; unticked options accept the value and ignore it for now.\n\n### Core (every view) — 41 options\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `view` | string | (from plugin) | Initial view name (see the per-plugin tables below for the full list). |\n| `views` | object | `{}` | Per-view overrides keyed by view name (e.g. `{ \"timeGridWeek\": { slotDuration: \"00:15\" } }`). |\n| `plugins` | string[] | `[]` | Which plugins to load: `\"DayGrid\"`, `\"TimeGrid\"`, `\"List\"`, `\"Resource\"`, `\"ResourceTimeGrid\"`, `\"ResourceTimeline\"`, `\"Interaction\"`. |\n| `date` | Date \\| ISO string | `today` | The date the calendar is anchored at; navigates around this point. |\n| `duration` | Duration | `{ weeks: 1 }` | How much time a view spans (`{days: 1}`, `\"01:00\"`, `90`s, etc.). |\n| `dateIncrement` | Duration | `duration` | Step size for prev/next buttons. |\n| `firstDay` | 0–6 | `0` (Sun) | First day of the week. |\n| `hiddenDays` | number[] | `[]` | Weekdays to hide (`[0, 6]` → weekends). |\n| `validRange` | `{ start, end }` | `undefined` | Restrict navigation to this window; outside dates render disabled. |\n| `height` | string \\| number | `undefined` | CSS height for the calendar shell. |\n| `theme` | object | (see `src/lib/theme.js`) | Map of CSS class names; pass a function `(prev) =\u003e merged` to extend. |\n| `locale` | string \\| object | `undefined` | `Intl` locale tag (e.g. `\"fr-FR\"`) or full locale object. |\n| `timeZone` | string | `\"local\"` | `\"local\"`, `\"UTC\"`, or a named IANA TZ (e.g. `\"Australia/Sydney\"`). |\n| `customScrollbars` | bool | `false` | Render the calendar's own scrollbars instead of the browser's. |\n| `viewDidMount` | fn(`{view, el}`) | `undefined` | Fires when a view is first rendered. |\n| `datesSet` | fn(`{start, end, view}`) | `undefined` | Fires when the displayed range changes. |\n| `loading` | fn(isLoading) | `undefined` | Fires when an event source starts/stops fetching. |\n| `lazyFetching` | bool | `true` | Re-hit event source URLs only when the view range changes. |\n| `highlightedDates` | (Date\\|string)[] | `[]` | Dates to render with the highlight theme. |\n| `titleFormat` | Intl format | `{ year:'numeric', month:'short', day:'numeric' }` | Toolbar title format. |\n| `dayHeaderFormat` | Intl format | `{ weekday:'short', month:'numeric', day:'numeric' }` | Column-header format. |\n| `dayHeaderAriaLabelFormat` | Intl format | `{ dateStyle: 'full' }` | Screen-reader header text. |\n| `icons` | object | `{}` | Map of icon name → `{ html }` or string; passing a function `(prev) =\u003e merged` extends. |\n| `buttonText` | object | (see below) | Map of button name → label; same merge semantics as `theme`. |\n| `customButtons` | object | `{}` | `{ name: { text, click, … } }` toolbar buttons. |\n| `headerToolbar` | `{ start, center, end }` | `{ start:'title', center:'', end:'today prev,next' }` | Toolbar layout; tokens: `title`, `today`, `prev`, `next`, `prevYear`, `nextYear`, any view name, or a custom-button name. Use a space (`prev,next`) for a button group. |\n| `events` | Event[] | `[]` | Inline event array. |\n| `eventSources` | EventSource[] | `[]` | Array of `{ events, url, color, classNames, ... }` sources (function sources OK). |\n| `eventFilter` | fn(event) → bool | `undefined` | Per-event include/exclude predicate. |\n| `eventOrder` | string \\| string[] \\| fn | `undefined` | Sort key(s) within a slot. |\n| `eventColor` | string | `undefined` | Shorthand setting bg + text. |\n| `eventBackgroundColor` | string | `undefined` | Per-event default; per-event `backgroundColor` wins. |\n| `eventTextColor` | string | `undefined` | Per-event default; per-event `textColor` wins. |\n| `eventClassNames` | string \\| string[] \\| fn | `undefined` | Extra classes on every event element. |\n| `eventContent` | fn(info) \\| `{ html }` \\| `{ domNodes }` \\| string | `undefined` | Replace the default event renderer. |\n| `eventDidMount` | fn(`{event, el, view}`) | `undefined` | Fires once each event is in the DOM. |\n| `eventTimeFormat` | Intl format | `{ hour:'numeric', minute:'2-digit' }` | Time text shown on each event. |\n| `displayEventEnd` | bool | `true` | Show end-time text on events. |\n| `eventClick` | fn(`{event, jsEvent}`) | `undefined` | Click handler. |\n| `eventMouseEnter` | fn(`{event, jsEvent}`) | `undefined` | Hover-in handler. |\n| `eventMouseLeave` | fn(`{event, jsEvent}`) | `undefined` | Hover-out handler. |\n| `eventAllUpdated` | fn() | `undefined` | Fires after every event re-render pass. |\n| `selectable` | bool | `false` | Allow dragging across cells to select a range (Interaction plugin). |\n\n### DayGrid plugin — adds the `dayGridDay`, `dayGridWeek`, `dayGridMonth` views\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `dayCellFormat` | Intl format | `{ day:'numeric' }` | Day-number format inside each cell. |\n| `dayCellContent` | fn(info) \\| string \\| `{ html }` | `undefined` | Replace the day-cell's body content. |\n| `dayMaxEvents` | bool \\| number | `false` | Collapse overflow into a \"+N more\" link; `true` = auto-fit. |\n| `moreLinkContent` | fn(info) \\| string | `undefined` | Replace the \"+N more\" link content. |\n| `dayPopoverFormat` | Intl format | `{ month:'long', day:'numeric', year:'numeric' }` | Header format for the day popover. |\n| `weekNumbers` | bool | `false` | Render an ISO/Western week-number column. |\n| `weekNumberContent` | fn(`{week, date}`) \\| string | `undefined` | Replace the week-number cell content. |\n\nAdds toolbar buttons `dayGridDay` / `dayGridWeek` / `dayGridMonth` (+ `close`).\n\n### TimeGrid plugin — adds the `timeGridDay`, `timeGridWeek` views\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `slotDuration` | Duration | `\"00:30:00\"` | Length of each row slot. |\n| `slotHeight` | number (px) | `24` | Pixel height per slot. |\n| `slotMinTime` | Duration | `\"00:00:00\"` | First visible time-of-day. |\n| `slotMaxTime` | Duration | `\"24:00:00\"` | Last visible time-of-day. |\n| `slotLabelInterval` | Duration | `undefined` | Time-axis label cadence (defaults to `slotDuration`). |\n| `slotLabelFormat` | Intl format | `{ hour:'numeric', minute:'2-digit' }` | Time-axis label format. |\n| `scrollTime` | Duration | `\"06:00:00\"` | Initial scroll position. |\n| `flexibleSlotTimeLimits` | bool \\| object | `false` | Auto-expand min/max when events spill outside. |\n| `nowIndicator` | bool | `false` | Draw the \"now\" red line. |\n| `slotEventOverlap` | bool | `true` | Render concurrent events overlapping (vs side-by-side). |\n| `allDaySlot` | bool | `true` | Show the all-day row. |\n| `allDayContent` | fn \\| string \\| `{html}` | `undefined` | Replace the all-day label. |\n| `columnWidth` | number (px) | `undefined` | Force a fixed day-column width (else auto-fits). |\n| `snapDuration` | Duration | `undefined` | Drag/resize/select grid resolution; falls back to `slotDuration`. |\n\nAdds toolbar buttons `timeGridDay` / `timeGridWeek`.\n\n### List plugin — adds the `listDay`, `listWeek`, `listMonth`, `listYear` views\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `listDayFormat` | Intl format | `{ weekday: 'long' }` | Day-header (left side) format. |\n| `listDaySideFormat` | Intl format | `{ year:'numeric', month:'long', day:'numeric' }` | Day-header (right side) format. |\n| `noEventsContent` | string \\| fn \\| `{html}` | `\"No events\"` | Empty-state body. |\n| `noEventsClick` | fn(`{jsEvent}`) | `undefined` | Click handler on the empty-state. |\n\nAdds toolbar buttons `listDay` / `listWeek` / `listMonth` / `listYear`.\n\n### Resource + ResourceTimeGrid plugin — adds `resourceTimeGridDay`, `resourceTimeGridWeek`\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `resources` | Resource[] \\| fn \\| EventSource-shape | `[]` | Resource axis: array of `{ id, title, eventBackgroundColor?, eventTextColor?, children? }`. |\n| `refetchResourcesOnNavigate` | bool | `false` | Re-fetch a function/URL resource source on prev/next. |\n| `datesAboveResources` | bool | `false` | Swap the axes — dates on top, resources on left, or vice versa. |\n| `resourceLabelContent` | fn(`{resource}`) \\| string | `undefined` | Replace the resource-label content. |\n| `resourceLabelDidMount` | fn(`{resource, el}`) | `undefined` | Fires once each resource label is in the DOM. |\n| `filterResourcesWithEvents` | bool | `false` | Hide resources with zero events in the active range. |\n| `filterEventsWithResources` | bool | `false` | Hide events whose `resourceIds` don't match any visible resource. |\n\nInherits every TimeGrid option (slots, all-day, now indicator). Adds toolbar\nbuttons `resourceTimeGridDay` / `resourceTimeGridWeek`.\n\n### ResourceTimeline plugin — adds `resourceTimelineDay/Week/Month/Year`\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `slotWidth` | number (px) | `32` | Width of each time-axis cell. |\n| `monthHeaderFormat` | Intl format | `{ month: 'long' }` | Header format for month/year timeline views. |\n| `resourceExpand` | bool \\| `'all'` \\| number | `undefined` | Auto-expand resource tree to depth N (use `'all'` for everything). |\n\nInherits every TimeGrid (slots / now indicator) and Resource (filter / label\nhooks) option. Adds toolbar buttons `resourceTimelineDay/Week/Month/Year`\nand `expand` / `collapse`.\n\n### Interaction plugin — drag, drop, resize, click-to-select\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `pointer` | bool | `false` | Enable pointer affordances (cursor changes, hover preview). |\n| `dateClick` | fn(`{date, allDay, jsEvent}`) | `undefined` | Click handler on an empty cell. |\n| `editable` | bool | `false` | Master switch — enables drag/resize on every event. |\n| `eventStartEditable` | bool | `true` | When `editable`, allow start-time edits. |\n| `eventDurationEditable` | bool | `true` | When `editable`, allow duration edits via resize. |\n| `eventResizableFromStart` | bool | `false` | Render a resize handle on the leading edge too. |\n| `eventDragStart` | fn(info) | `undefined` | Fires when a drag begins. |\n| `eventDragStop` | fn(info) | `undefined` | Fires when a drag ends (before drop). |\n| `eventDrop` | fn(`{event, oldEvent, delta, revert}`) | `undefined` | Fires on drop — call `revert()` to undo. |\n| `eventResizeStart` | fn(info) | `undefined` | Fires when a resize begins. |\n| `eventResizeStop` | fn(info) | `undefined` | Fires when a resize ends (before commit). |\n| `eventResize` | fn(`{event, oldEvent, endDelta, revert}`) | `undefined` | Fires on commit — call `revert()` to undo. |\n| `eventDragMinDistance` | number (px) | `5` | Pointer-move threshold before a drag starts. |\n| `eventLongPressDelay` | number (ms) | `undefined` | Override the global long-press for events. |\n| `dragConstraint` | object | `undefined` | Restrict drag destinations (e.g. `{ resources: ['1', '2'] }`). |\n| `dragScroll` | bool | `true` | Auto-scroll while dragging near an edge. |\n| `snapDuration` | Duration | `undefined` | Grid resolution for drag/resize/select. |\n| `resizeConstraint` | object | `undefined` | Restrict resize bounds. |\n| `selectable` | bool | `false` | Allow click-drag selection across cells. |\n| `select` | fn(`{start, end, allDay, jsEvent}`) | `undefined` | Fires on a selection. |\n| `unselect` | fn(`{jsEvent}`) | `undefined` | Fires when the selection clears. |\n| `unselectAuto` | bool | `true` | Clear selection on outside-click. |\n| `unselectCancel` | string | `\"\"` | CSS selector for elements that *don't* clear the selection. |\n| `selectBackgroundColor` | string | `undefined` | Override the selection highlight colour. |\n| `selectConstraint` | object | `undefined` | Restrict the bounds of a selection. |\n| `selectMinDistance` | number (px) | `5` | Pointer-move threshold before a selection starts. |\n| `selectLongPressDelay` | number (ms) | `undefined` | Override the global long-press for selections. |\n| `longPressDelay` | number (ms) | `1000` | Touch long-press threshold for any pointer interaction. |\n\n### stimulus_calendar extensions (not in any upstream calendar library)\n\n| Attribute | Type | Default | What it does |\n|---|---|---|---|\n| `data-calendar-event-source-value` | URL string | `undefined` | Convenience for an URL event source — equivalent to `eventSources: [{ url }]`. |\n| `data-calendar-resource-source-value` | URL string | `undefined` | Same idea for resources. |\n| `data-calendar-broadcast-value` | string | `false` | Broadcast adapter: `\"turbo-stream\"`, `\"action-cable\"`, `\"websocket\"`, `\"broadcast-channel\"`, or `false` to disable. |\n| `data-calendar-broadcast-channel-value` | string | `undefined` | Channel name (BroadcastChannel) or URL (WebSocket / ActionCable). |\n| `data-calendar-broadcast-filter-value` | fn (set via `setOption`) | `undefined` | Predicate that decides which local mutations broadcast. |\n\n### Built-in event shape (for `events` / `eventSources`)\n\n| Key | Type | Notes |\n|---|---|---|\n| `id` | string | Required for any event you'll later update or remove. |\n| `resourceIds` | string[] | Used by the Resource views. |\n| `allDay` | bool | If omitted, inferred from `noTimePart(start)`. |\n| `start` | Date \\| ISO string | Required. |\n| `end` | Date \\| ISO string | Optional. |\n| `title` | string | Optional display text. |\n| `editable` / `startEditable` / `durationEditable` | bool | Per-event overrides of the matching global options. |\n| `display` | `\"auto\"` \\| `\"background\"` | `\"background\"` draws as a translucent band, not as an event chip. |\n| `backgroundColor` / `textColor` / `color` | string | Per-event styling. |\n| `classNames` | string \\| string[] | Extra classes. |\n| `extendedProps` | object | Anything you want carried with the event (passed to your callbacks unchanged). |\n\n### Built-in resource shape (for `resources`)\n\n| Key | Type | Notes |\n|---|---|---|\n| `id` | string | Required. |\n| `title` | string | Display text. |\n| `children` | Resource[] | Nested resources (rendered as an expandable tree). |\n| `eventBackgroundColor` / `eventTextColor` | string | Default colour applied to events on this resource. |\n| `extendedProps` | object | Anything you want carried with the resource. |\n\n## Events (dispatched on the calendar element)\n\nThe matching `data-calendar-\u003cevent\u003e-value` callback option fires for the\nsame event — pick whichever is more ergonomic. All events bubble.\n\n| Event | `detail` shape | Fires when |\n|---|---|---|\n| `calendar:ready` | `{ api }` | Controller has mounted and `element.calendarApi` is callable. |\n| `calendar:datesSet` | `{ start, end, view }` | The active date range changes (nav, view switch). |\n| `calendar:viewDidMount` | `{ view, el }` | A view's DOM is first created. |\n| `calendar:eventClick` | `{ event, jsEvent, view }` | User clicks an event. |\n| `calendar:eventMouseEnter` | `{ event, jsEvent, view }` | Hover-in. |\n| `calendar:eventMouseLeave` | `{ event, jsEvent, view }` | Hover-out. |\n| `calendar:eventDidMount` | `{ event, el, view }` | An event's DOM node is first inserted. |\n| `calendar:eventAllUpdated` | `{}` | After every event re-render pass. |\n| `calendar:dateClick` | `{ date, allDay, jsEvent, view, resource? }` | Click on an empty cell. |\n| `calendar:eventDragStart` | `{ event, jsEvent, view }` | Drag begins. |\n| `calendar:eventDragStop` | `{ event, jsEvent, view }` | Drag ends (before drop applies). |\n| `calendar:eventDrop` | `{ event, oldEvent, delta, jsEvent, view, revert }` | Drop applied — call `revert()` to undo. |\n| `calendar:eventResizeStart` | `{ event, jsEvent, view }` | Resize begins. |\n| `calendar:eventResizeStop` | `{ event, jsEvent, view }` | Resize ends (before commit). |\n| `calendar:eventResize` | `{ event, oldEvent, endDelta, jsEvent, view, revert }` | Resize commit — call `revert()` to undo. |\n| `calendar:select` | `{ start, end, allDay, jsEvent, view, resource? }` | Drag-selection commits. |\n| `calendar:unselect` | `{ jsEvent }` | Selection clears. |\n| `calendar:loading` | `{ isLoading }` | Event source starts/stops a fetch. |\n| `calendar:broadcast:out` | `{ message }` | A local mutation is about to be published. |\n| `calendar:broadcast:in` | `{ message }` | A broadcast arrived from another tab/user. |\n\n```js\ncalendar.addEventListener(\"calendar:ready\",     (e) =\u003e e.detail.api.addEvent({ id: \"10\", title: \"...\" }))\ncalendar.addEventListener(\"calendar:eventDrop\", (e) =\u003e save(e.detail).catch(e.detail.revert))\n```\n\n## Public API — `element.calendarApi`\n\nAvailable after `calendar:ready`. Every method below is built in.\n\n### Events\n\n| Method | Signature | What it does |\n|---|---|---|\n| `addEvent` | `(event) → event` | Insert one event; returns the normalised event. |\n| `updateEvent` | `(event) → event` | Patch an event by id; missing fields are preserved. |\n| `removeEventById` | `(id) → void` | Delete an event. |\n| `getEvents` | `() → event[]` | All events currently in the dataset (post-filter). |\n| `getEventById` | `(id) → event \\| undefined` | Lookup. |\n| `refetchEvents` | `() → Promise\u003cvoid\u003e` | Re-hit every event source URL/function. |\n\n### Resources\n\n| Method | Signature | What it does |\n|---|---|---|\n| `refetchResources` | `() → Promise\u003cvoid\u003e` | Re-hit a function/URL resource source. |\n| `getResources` | `() → resource[]` | Current resource tree (flat array). |\n\n### Navigation\n\n| Method | Signature | What it does |\n|---|---|---|\n| `next` | `() → void` | Advance by `dateIncrement` (defaults to `duration`). |\n| `prev` | `() → void` | Reverse by `dateIncrement`. |\n| `today` | `() → void` | Snap back to the current day. |\n| `gotoDate` | `(date) → void` | Navigate to a specific date. |\n| `getView` | `() → { type, title, currentStart, currentEnd, activeStart, activeEnd }` | Active view snapshot. |\n| `setOption` | `(name, value) → void` | Change any option at runtime. |\n| `getOption` | `(name) → any` | Read the current value of any option. |\n| `dateFromPoint` | `(x, y) → { date, allDay, resource? } \\| null` | Map screen coordinates back to a calendar coordinate. |\n\n### Selection\n\n| Method | Signature | What it does |\n|---|---|---|\n| `unselect` | `() → void` | Clear any active selection. |\n\n### Lifecycle (IIFE entry point)\n\n| Method | Signature | What it does |\n|---|---|---|\n| `StimulusCalendar.create` | `(element, options) → calendar` | Boot a calendar imperatively, no Stimulus needed. |\n| `StimulusCalendar.destroy` | `(element) → void` | Tear down a calendar booted via `create`. |\n| `StimulusCalendar.start` | `(app?) → Application` | Register Stimulus controllers and return the Application. |\n\n```js\nconst api = document.querySelector('[data-controller~=\"calendar\"]').calendarApi\napi.addEvent({ id: \"1\", title: \"Lunch\", start: \"2026-05-25T12:00\", end: \"2026-05-25T13:00\" })\napi.next()\n```\n\n## Turbo Streams broadcasting\n\nWhen one user adds, drags or resizes an event, every other connected user sees\nit instantly. **Transport-agnostic** core, with adapters for:\n\n- `turbo-stream` — `\u003cturbo-stream action=\"calendar-event-{add,update,remove}\"\u003e` custom actions over Action Cable; the Rails gem (Option C) wires this end-to-end.\n- `action-cable` — direct channel subscription, no Turbo Stream wrapper.\n- `websocket` — raw WebSocket, server format defined by your app.\n- `broadcast-channel` — `window.BroadcastChannel` for tab-to-tab sync within\n  a single browser (great for demos, no server needed).\n\nPayload schema and the Rails recipe (`broadcasts_calendar`) are documented in\n[`docs/BROADCAST.md`](docs/BROADCAST.md). For a step-by-step integration\nwalkthrough (model + calendar + partial + multi-tenancy + verification),\nsee [`docs/LIVE_SYNC_RAILS.md`](docs/LIVE_SYNC_RAILS.md).\n\n## Rails \u0026amp; Hotwire (`stimulus_calendar_rails`)\n\nFor Rails apps, the **[`stimulus_calendar_rails`](gem/stimulus_calendar_rails)**\ngem turns the calendar into a **server-driven, multi-user editable** calendar\nover Turbo Streams + Action Cable — no React, no client-side scheduling\nframework, no JS build step. Because a Rails app knows its schema, the\n**server** event definition does the work a generic client calendar pushes\nonto the browser: auth, coercion, validation, and broadcasting.\n\n**Capabilities** (target — ships in Phase 14):\n\n- **Live multi-user editing** — every create/update/destroy broadcasts\n  `calendar-event-*` Turbo Stream actions to every connected tab.\n- **Optimistic edits** — a dragged event applies immediately, then the server\n  reconciles (or reverts with errors), with `X-Optimistic-Id` echo-suppression\n  for the originator.\n- **Server-side event registry** — per-field `type`, `editable` (boolean *or*\n  lambda), `validate`, `concurrency`.\n- **Concurrency \u0026amp; validation** — version-checked moves (`lock_version`\n  → conflict), server-side validation → revert with errors.\n- **Multi-tenancy \u0026amp; auth** — tenant-scoped streams (ActsAsTenant), scoped\n  row lookups, and auth inherited from your `parent_controller`.\n\n**Install** — published on RubyGems as\n[`stimulus_calendar_rails`](https://rubygems.org/gems/stimulus_calendar_rails).\nAdd it with Bundler:\n\n```bash\nbundle add stimulus_calendar_rails\n```\n\n```js\n// app/javascript/application.js\nimport \"@hotwired/turbo-rails\"\nimport { Application } from \"@hotwired/stimulus\"\nimport StimulusCalendar from \"stimulus_calendar\"\nimport StimulusCalendarRails from \"stimulus_calendar_rails\"\n\nconst application = Application.start()\nStimulusCalendar.start(application)        // calendar (+ header-toolbar, day-cell, …)\nStimulusCalendarRails.start(application)   // calendar-sync + Turbo Stream actions\n```\n\n```erb\n\u003c%# app/views/layouts/application.html.erb (head) %\u003e\n\u003c%= stylesheet_link_tag \"stimulus_calendar\", \"stimulus_calendar_rails\" %\u003e\n\u003c%= javascript_importmap_tags %\u003e\n```\n\n```ruby\n# config/routes.rb\nmount ActionCable.server =\u003e \"/cable\"\nmount StimulusCalendarRails::Engine =\u003e StimulusCalendarRails.mount_path   # default \"/calendars\"\n```\n\n**Usage**\n\n```ruby\n# app/calendars/event_calendar.rb — one source of truth for the schema\nclass EventCalendar \u003c StimulusCalendarRails::Calendar\n  resource :events\n  model    Event\n  field :title,       type: :string,   editable: true\n  field :starts_at,   type: :datetime, editable: true, concurrency: :version_checked\n  field :ends_at,     type: :datetime, editable: true, concurrency: :version_checked,\n                      validate: -\u003e(v, row) { \"end must be after start\" if v \u003c= row.starts_at }\n  field :resource_id, type: :reference, editable: -\u003e(row, user) { user\u0026.admin? }\nend\n```\n\n```ruby\n# app/models/event.rb — make the model broadcast its changes\nclass Event \u003c ApplicationRecord\n  include StimulusCalendarRails::Broadcastable\n  broadcasts_calendar EventCalendar\n  self.locking_column = :lock_version   # needed for version-checked fields\nend\n```\n\n```erb\n\u003c%# render it anywhere %\u003e\n\u003c%= render partial: \"stimulus_calendar_rails/calendars/calendar\",\n           locals: { calendar: EventCalendar.new(user: current_user),\n                     events: Event.between(@start, @end),\n                     view: \"timeGridWeek\" } %\u003e\n```\n\nDrag an event → optimistic move → the server persists or reverts → every\nother connected tab updates live. A complete runnable app is in\n[`gem/demo`](gem/demo); full docs in\n[`gem/stimulus_calendar_rails/README.md`](gem/stimulus_calendar_rails/README.md)\nand [`RAILS.md`](RAILS.md).\n\n**Building this integration?** Read\n[`docs/LIVE_SYNC_RAILS.md`](docs/LIVE_SYNC_RAILS.md) first — it's an\nend-to-end cookbook for wiring a model + `Broadcastable` +\n`turbo_stream_from` so an LLM (or human) gets the live-sync chain right\nthe first time, including the multi-tenancy traps and a verification\nladder. The companion docs are\n[`docs/BROADCAST.md`](docs/BROADCAST.md) (wire format) and\n[`docs/RAILS_REFERENCE.md`](docs/RAILS_REFERENCE.md) (API surface).\n\n## Demos\n\n`npm install \u0026\u0026 npx vite`, then open `http://localhost:5173/demo/` — demo\npages cover basics, month/week/list/resource views, drag/drop/resize, and the\ntwo-window BroadcastChannel sync example. Demos land per\n[PLAN.md](./PLAN.md) phase.\n\nFor the Rails companion, `cd gem/demo \u0026\u0026 bin/rails server` then open\n`http://localhost:3000/calendars`. Open the same URL in a second window and\ndrag an event — both windows update live.\n\n## Build\n\n```bash\nnpm run build:lib   # dist/stimulus_calendar.js (IIFE) + dist/stimulus_calendar.esm.js (ESM) + .css\n```\n\n## Tests\n\n```bash\nnpm test                          # JS core (Vitest)\ncd gem/demo \u0026\u0026 bin/rails test     # Rails engine: models, controllers, Turbo Streams broadcasts\n```\n\nBoth run on every push/PR via [GitHub Actions](.github/workflows/ci.yml).\n\nSee [`RAILS.md`](RAILS.md) for the Hotwire-Native build checklist,\n[`docs/REFERENCE.md`](docs/REFERENCE.md) for the full programmatic API (lands\nin Phase 15), and [`skills/`](skills) for LLM-oriented usage guides.\n\n## License\n\nMIT — see [LICENSE](./LICENSE). Portions ported from\n[vkurko/calendar](https://github.com/vkurko/calendar) (MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschappim%2Fstimulus_calendar","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fschappim%2Fstimulus_calendar","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschappim%2Fstimulus_calendar/lists"}