@ziix/calendar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ziix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,359 @@
1
+ # @ziix/calendar
2
+
3
+ A framework-agnostic resource & time-grid calendar — a from-scratch, **license-free**
4
+ replacement for the FullCalendar views DMS uses (`timeGridDay`, `resourceTimeGridDay`,
5
+ `resourceTimeline`). No premium scheduler licence, no React/Vue/Preact dependency: a
6
+ plain imperative class you drive through a ref from any framework.
7
+
8
+ > **Status.** All three views (`day`, `resource-day`, `timeline`) and the full interaction
9
+ > engine (drag/resize/select) are implemented and tested. Remaining work is polish, the DMS
10
+ > migration and the npm release — see the roadmap below.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @ziix/calendar dayjs
16
+ ```
17
+
18
+ `dayjs` is a peer dependency (the calendar uses it for timezone-correct date math).
19
+
20
+ ## Quick start
21
+
22
+ ```js
23
+ import { Calendar } from '@ziix/calendar'
24
+ import '@ziix/calendar/styles.css'
25
+
26
+ const cal = new Calendar(document.getElementById('calendar'), {
27
+ view: 'day',
28
+ timezone: 'Europe/Copenhagen', // events are placed in the shop's clock, not the browser's
29
+ date: '2026-06-09',
30
+ height: 780,
31
+ slot: { duration: 15, min: '06:00', max: '19:00', labelInterval: 60 },
32
+ nowIndicator: true,
33
+ locale: { code: 'da', intl: 'da-DK', firstDay: 1 },
34
+ events: [
35
+ { id: 1, title: 'Service', start: '2026-06-09T08:00:00+02:00', end: '2026-06-09T09:00:00+02:00' },
36
+ ],
37
+ onEventClick: ({ event }) => console.log('clicked', event.title),
38
+ })
39
+
40
+ cal.render()
41
+ ```
42
+
43
+ The host element gets a `.zc` class and owns its own height (set `height`, or give the
44
+ element a height in CSS). Call `cal.destroy()` when you tear the component down.
45
+
46
+ ### Loading events from a server
47
+
48
+ `events` may be an array (above) **or** a function that receives the visible range and
49
+ returns events — it re-runs automatically on navigation:
50
+
51
+ ```js
52
+ events: async ({ start, end }) => {
53
+ // start / end are Dayjs objects in the calendar timezone
54
+ const res = await fetch(`/calendar/events?from=${start.toISOString()}&to=${end.toISOString()}`)
55
+ return (await res.json()).events
56
+ }
57
+ ```
58
+
59
+ ## Timeline view (resources as rows)
60
+
61
+ This is the `resourceTimeline` replacement: a sticky resource area on the left and a
62
+ horizontally-scrolling time grid on the right. Pass `resources` (array or function) and
63
+ set `view: 'timeline'`.
64
+
65
+ ```js
66
+ const cal = new Calendar(el, {
67
+ view: 'timeline',
68
+ timezone: 'Europe/Copenhagen',
69
+ height: 780,
70
+ slot: { duration: 15, min: '06:00', max: '19:00' },
71
+ nowIndicator: true,
72
+
73
+ // group resources into header bands by this field
74
+ resourceGroupField: 'group',
75
+
76
+ // the sticky left area: one or more columns
77
+ resourceArea: {
78
+ width: '25%',
79
+ columns: [
80
+ { field: 'title', header: 'Afdelinger' }, // plain text from resource.title
81
+ { header: 'Tider', render: (r) => renderHoursCell(r) }, // custom HTML / DOM node per resource
82
+ ],
83
+ },
84
+
85
+ resources: [
86
+ { id: 'E1', title: 'Anders', group: 'Mekanik' },
87
+ { id: 'E2', title: 'Bo', group: 'Mekanik' },
88
+ { id: 'C1', title: 'Lånebil 1', group: 'Biler' },
89
+ ],
90
+
91
+ events: [
92
+ { id: 1, title: 'Service', start: '...', end: '...', resourceId: 'E1' },
93
+ ],
94
+
95
+ // custom label for the default resource column (FullCalendar's resourceLabelContent)
96
+ renderResource: (resource) => `<strong>${resource.title}</strong>`,
97
+
98
+ onEventClick: ({ event }) => openOrder(event.extendedProps.orderId),
99
+ })
100
+ cal.render()
101
+ ```
102
+
103
+ Events on the same resource that overlap in time stack into vertical levels and the row
104
+ grows to fit them. An event without a matching `resourceId` is not shown in the timeline.
105
+
106
+ ### Resource-day view (resources as columns)
107
+
108
+ `view: 'resource-day'` is the `resourceTimeGridDay` equivalent: the same vertical time axis
109
+ as the day view, but with one column per resource under a sticky, grouped header. Takes the
110
+ same `resources` / `resourceGroupField` / `renderResource` options as the timeline. With
111
+ `editable`, dragging an event sideways moves it to another resource column.
112
+
113
+ ## Data shapes
114
+
115
+ **Event input** (`color`/`textColor`/`extendedProps` optional; extra keys are preserved on
116
+ `event.raw`):
117
+
118
+ ```ts
119
+ { id, title?, start, end?, resourceId?, allDay?, color?, textColor?, extendedProps? }
120
+ ```
121
+
122
+ `start` / `end` accept anything dayjs parses (ISO string with offset is safest). Inside the
123
+ calendar they become `event.start` / `event.end` Dayjs objects in the configured timezone.
124
+
125
+ **Resource input:**
126
+
127
+ ```ts
128
+ { id, title?, group?, order?, ...customFields }
129
+ ```
130
+
131
+ Custom fields (e.g. `make`, `workHours`) are available as `resource.raw.<field>` in
132
+ `renderResource` / column `render`.
133
+
134
+ ## Options reference
135
+
136
+ | Option | Type | Notes |
137
+ | --- | --- | --- |
138
+ | `view` | `'day' \| 'resource-day' \| 'timeline'` | default `'day'` |
139
+ | `date` | `string \| Date` | initial date; default today |
140
+ | `timezone` | `string` | IANA tz; events are placed in this clock |
141
+ | `locale` | `string \| { code, intl?, firstDay?, buttons? }` | `intl` is the BCP-47 tag for formatting |
142
+ | `firstDay` | `number` | 0 = Sunday; default 1 |
143
+ | `slot` | `{ duration?, min?, max?, labelInterval? }` | minutes / `'HH:mm'` / minutes |
144
+ | `height` | `number \| string` | applied to the host element |
145
+ | `eventMinHeight` | `number` | min height (px) of a stacked event in the timeline; row grows to fit. Default 48 |
146
+ | `nowIndicator` | `boolean` | current-time line |
147
+ | `dayClosed` | `boolean \| ((date) => boolean)` | tint the day as closed/non-business (`--zc-nonbusiness`) |
148
+ | `toolbar` | `{ start?, center?, end? } \| false` | space-separated tokens: `today prev next title <customKey>` |
149
+ | `buttons` | `{ [key]: { text?, icon?, onClick } }` | custom toolbar buttons |
150
+ | `events` | array \| `(range) => events \| Promise<events>` | function re-runs on navigation |
151
+ | `resources` | array \| `(range) => resources \| Promise<…>` | timeline / resource-day |
152
+ | `resourceArea` | `{ width?, columns? }` | sticky left area (timeline) |
153
+ | `resourceGroupField` | `string` | field to group rows by; default `'group'` |
154
+ | `resourceOrder` | `string` | field to sort by; default `'order'` (use `'id'` for id sort) |
155
+ | `renderEvent` | `(event) => string \| HTMLElement` | custom event body |
156
+ | `renderResource` | `(resource) => string \| HTMLElement` | custom resource label |
157
+ | `timeFormat` | `Intl.DateTimeFormatOptions` | default event time format |
158
+ | `editable` | `boolean` | enable drag-move + resize |
159
+ | `selectable` | `boolean` | enable drag-select of empty ranges |
160
+ | `eventOverlap` | `boolean \| (() => boolean)` | allow overlapping events on drop; default `true` |
161
+ | `selectAllow` | `({ start, end, resource }) => boolean` | gate which ranges can be selected |
162
+ | `onEventClick` | `({ event, el, jsEvent }) => void` | |
163
+ | `onEventContextMenu` | `({ event, el, jsEvent }) => void` | right-click; native menu suppressed |
164
+ | `onEventMount` | `({ event, el }) => void` | bind deep-link highlight / extra listeners here |
165
+ | `onEventChange` | `({ event, oldEvent }) => void` | after a drag/resize commit |
166
+ | `onSelect` | `({ start, end, resource, jsEvent }) => void` | after a drag-select |
167
+ | `onDatesSet` | `({ start, end, view }) => void` | fires on navigation / view change |
168
+ | `onEventsSet` | `(events) => void` | after each event load |
169
+
170
+ ## Editing — drag, resize, select
171
+
172
+ Set `editable: true` to let users drag events to a new time (and, in the timeline, a new
173
+ resource row) and resize them by their edges. Set `selectable: true` to let users
174
+ drag-select an empty range. Everything snaps to `slot.duration`.
175
+
176
+ ```js
177
+ const cal = new Calendar(el, {
178
+ view: 'timeline',
179
+ editable: true,
180
+ selectable: true,
181
+
182
+ // false ⇒ a drop/resize that would overlap another event on the same resource is
183
+ // rejected and snaps back. May be a function evaluated per drop.
184
+ eventOverlap: () => shop.settings.calendar_overlap,
185
+
186
+ // gate which ranges may be selected (e.g. only employee or rental-car rows)
187
+ selectAllow: ({ resource }) => !!resource && /^[EC]/.test(resource.id),
188
+
189
+ // fired after a successful drag/resize — persist it to your backend here
190
+ onEventChange: ({ event, oldEvent, revert }) => {
191
+ api.patch(`/calendar/event/${event.id}`, {
192
+ from: event.start.toISOString(),
193
+ to: event.end.toISOString(),
194
+ resource: event.resourceId,
195
+ }).catch(() => revert()) // server rejected the move → snap the event back
196
+ },
197
+
198
+ // fired after a drag-select — open a "new event" menu, etc.
199
+ onSelect: ({ start, end, resource }) => openNewEventMenu(start, end, resource),
200
+ })
201
+ ```
202
+
203
+ Behaviour notes:
204
+
205
+ - **Day view:** drag moves vertically (time only); resize from the bottom edge.
206
+ - **Timeline:** drag moves horizontally (time) and vertically (across resource rows);
207
+ resize from either edge.
208
+ - A rejected move (overlap / out of bounds) reverts automatically — `onEventChange` does
209
+ **not** fire.
210
+ - A plain click on an editable event still fires `onEventClick` (distinguished from a drag
211
+ by a movement threshold).
212
+
213
+ ## Context menus
214
+
215
+ Right-click on an event fires `onEventContextMenu` (the native browser menu is suppressed
216
+ first). The calendar deliberately does **not** ship a menu UI — you render your own from the
217
+ hook, so it matches your app. Works in all three views, on read-only and editable events.
218
+
219
+ ```js
220
+ const cal = new Calendar(el, {
221
+ onEventContextMenu: ({ event, jsEvent }) => {
222
+ myMenu.open(jsEvent.clientX, jsEvent.clientY, [
223
+ { label: 'Open order', run: () => openOrder(event.extendedProps.orderId) },
224
+ { label: 'Delete', run: () => cal.getEventById(event.id)?.remove() },
225
+ ])
226
+ },
227
+ })
228
+ ```
229
+
230
+ For a context menu on **empty** space (e.g. "create here"), use `onSelect` — a drag-select
231
+ gives you `{ start, end, resource }` to anchor the menu. See `examples/index.html` for a
232
+ working menu implementation.
233
+
234
+ ## Imperative API
235
+
236
+ The calendar is a plain class you drive through a ref — mirrors the surface FullCalendar
237
+ consumers rely on:
238
+
239
+ | Method | Purpose |
240
+ | --- | --- |
241
+ | `render()` / `destroy()` | mount / tear down |
242
+ | `refetchEvents()` | re-run the events source for the current range |
243
+ | `reload()` | re-fetch resources **and** events |
244
+ | `addEvent(input)` | add one event, returns an `EventHandle` |
245
+ | `getEventById(id)` | returns `{ event, remove(), setExtendedProp() }` or `null` |
246
+ | `getEvents()` / `getResources()` | read stores |
247
+ | `getResourceById(id)` | returns a `ResourceHandle` — `{ resource, setExtendedProp(), setProp() }` for pushing live data (work hours, punch-ins) into a resource column |
248
+ | `gotoDate(date)` / `today()` / `prev()` / `next()` | navigation |
249
+ | `changeView('day' \| 'resource-day' \| 'timeline')` | switch view |
250
+ | `getView()` | `{ type, activeStart, activeEnd }` for the current range |
251
+ | `view` / `date` / `activeStart` / `activeEnd` (getters) | current state |
252
+
253
+ ### Real-time updates
254
+
255
+ Drive incremental updates from a websocket without a full refetch (DMS uses Laravel Echo):
256
+
257
+ ```js
258
+ echo.private(`shop.${id}`)
259
+ .listen('.eventCreated', (e) => cal.addEvent(e.event))
260
+ .listen('.eventUpdated', () => cal.refetchEvents())
261
+ .listen('.eventRemoved', (e) => cal.getEventById(e.id)?.remove())
262
+ ```
263
+
264
+ ### Using it from Preact / React
265
+
266
+ Because the calendar is framework-agnostic, you mount it on a ref and drive it
267
+ imperatively — no wrapper component needed:
268
+
269
+ ```jsx
270
+ import { useEffect, useRef } from 'preact/hooks'
271
+ import { Calendar } from '@ziix/calendar'
272
+ import '@ziix/calendar/styles.css'
273
+
274
+ export function CalendarView({ shopId }) {
275
+ const elRef = useRef(null)
276
+ const calRef = useRef(null)
277
+
278
+ useEffect(() => {
279
+ const cal = new Calendar(elRef.current, {
280
+ view: 'timeline',
281
+ timezone: window.ziix.timezone,
282
+ events: ({ start, end }) => fetchEvents(shopId, start, end),
283
+ resources: () => fetchResources(shopId),
284
+ onEventClick: ({ event }) => openOrder(event),
285
+ })
286
+ cal.render()
287
+ calRef.current = cal
288
+ return () => cal.destroy()
289
+ }, [shopId])
290
+
291
+ return <div ref={elRef} style={{ height: 780 }} />
292
+ }
293
+ ```
294
+
295
+ ## Translations & locale
296
+
297
+ The calendar renders almost no text of its own — column headers, resource labels and
298
+ event content all come from **your** render hooks, so they're already in your language.
299
+ The only built-in strings are the toolbar buttons, and the date in the title. Both are
300
+ driven by the `locale` option — pass your app's translations there (DMS feeds its
301
+ `trans()` values straight in):
302
+
303
+ ```js
304
+ const cal = new Calendar(el, {
305
+ locale: {
306
+ code: 'da',
307
+ intl: 'da-DK', // BCP-47 tag used by Intl to format the title date
308
+ firstDay: 1,
309
+ buttons: { today: trans('e.today'), prev: '‹', next: '›' },
310
+ ariaLabels: { today: trans('e.today'), prev: trans('e.prev'), next: trans('e.next') },
311
+ },
312
+ })
313
+ ```
314
+
315
+ There are no hardcoded user-facing strings in the library — anything visible is either
316
+ supplied by you (hooks) or overridable here. The title date is localised automatically
317
+ via `Intl` using `intl` (falling back to `code`).
318
+
319
+ ## Theming
320
+
321
+ Every colour is a `--zc-*` custom property on `.zc`. Remap them to your design tokens —
322
+ no need to touch internals:
323
+
324
+ ```css
325
+ .zc {
326
+ --zc-border: var(--color-border);
327
+ --zc-today-bg: var(--color-primary-50);
328
+ --zc-event-bg: var(--color-primary-200);
329
+ --zc-event-border: var(--color-primary-300);
330
+ --zc-event-fg: var(--color-primary-700);
331
+ --zc-now: var(--color-danger-500);
332
+ --zc-nonbusiness: var(--color-muted); /* closed-day tint */
333
+ }
334
+ ```
335
+
336
+ ## Develop
337
+
338
+ ```bash
339
+ npm install
340
+ npm run dev # Vite playground (examples/index.html)
341
+ npm test # vitest (datelib, overlap logic + day/timeline DOM render)
342
+ npm run typecheck # tsc --noEmit
343
+ npm run build # dist/ziix-calendar.js + .css + index.d.ts
344
+ ```
345
+
346
+ ## Roadmap
347
+
348
+ | Fase | Scope | State |
349
+ | --- | --- | --- |
350
+ | 0 | Core: Calendar class, stores, datelib, toolbar, navigation, theming | ✅ |
351
+ | 1 | `day` view: time axis, overlap packing, now indicator, event hooks | ✅ |
352
+ | 3 | `timeline` view (resources as rows, horizontal axis, grouping, custom resource columns, event stacking) | ✅ |
353
+ | 4 | Interaction engine: drag-move, resize, drag-select, overlap/allow gating | ✅ |
354
+ | 2 | `resource-day` view (resources as columns, grouped header, cross-column move) | ✅ |
355
+ | 5 | Locale pack, deep-link highlight, a11y polish | ⏳ |
356
+
357
+ ## License
358
+
359
+ MIT