resourcenest 1.2.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.
@@ -0,0 +1,845 @@
1
+ /**
2
+ * @dennisthln/resourcenest
3
+ * Professional resource calendar UI for displaying and managing resource scheduling.
4
+ * Works with any framework: Vue, React, Angular, plain HTML, etc.
5
+ */
6
+
7
+ // ============ UTILITIES ============
8
+ function formatTime(date) {
9
+ const h = date.getHours().toString().padStart(2, '0');
10
+ const m = date.getMinutes().toString().padStart(2, '0');
11
+ return `${h}:${m}`;
12
+ }
13
+
14
+ function formatDate(date, format = 'DD.MM.YYYY') {
15
+ const d = date.getDate().toString().padStart(2, '0');
16
+ const m = (date.getMonth() + 1).toString().padStart(2, '0');
17
+ const y = date.getFullYear();
18
+ return format.replace('DD', d).replace('MM', m).replace('YYYY', y);
19
+ }
20
+
21
+ function startOfDay(date) {
22
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
23
+ }
24
+
25
+ function addDays(date, days) {
26
+ const result = new Date(date);
27
+ result.setDate(result.getDate() + days);
28
+ return result;
29
+ }
30
+
31
+ function isSameDay(date1, date2) {
32
+ return date1.getFullYear() === date2.getFullYear() &&
33
+ date1.getMonth() === date2.getMonth() &&
34
+ date1.getDate() === date2.getDate();
35
+ }
36
+
37
+ function roundToMinutes(date, minutes = 5) {
38
+ const ms = 1000 * 60 * minutes;
39
+ return new Date(Math.round(date.getTime() / ms) * ms);
40
+ }
41
+
42
+ // ============ DEFAULT OPTIONS ============
43
+ const DEFAULTS = {
44
+ startHour: 8,
45
+ endHour: 22,
46
+ events: [],
47
+ resources: [],
48
+ locale: 'de-DE',
49
+ dateFormat: 'DD.MM.YYYY',
50
+ todayLabel: 'Today',
51
+ loadingLabel: 'Loading...',
52
+ showNavigation: true,
53
+ showNowIndicator: true,
54
+ showViewSwitcher: true,
55
+ eventClickable: true,
56
+ timeClickable: true,
57
+ // View modes: 'day', '3days', 'week'
58
+ view: 'day',
59
+ viewLabels: {
60
+ day: 'Day',
61
+ '3days': '3 Days',
62
+ week: 'Week',
63
+ },
64
+ // Callbacks
65
+ onEventClick: null,
66
+ onTimeClick: null,
67
+ onDateChange: null,
68
+ onViewChange: null,
69
+ };
70
+
71
+ // ============ STYLES ============
72
+ const CALENDAR_STYLES = `
73
+ .rn-timeline-calendar {
74
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
75
+ background: #fff;
76
+ border-radius: 8px;
77
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
78
+ padding: 1rem;
79
+ box-sizing: border-box;
80
+ }
81
+ .rn-timeline-calendar *, .rn-timeline-calendar *::before, .rn-timeline-calendar *::after {
82
+ box-sizing: border-box;
83
+ }
84
+ .rn-calendar-header {
85
+ display: flex;
86
+ justify-content: space-between;
87
+ align-items: center;
88
+ margin-bottom: 1rem;
89
+ flex-wrap: wrap;
90
+ gap: 0.5rem;
91
+ }
92
+ .rn-date-display {
93
+ font-size: 1.25rem;
94
+ font-weight: 700;
95
+ color: #111827;
96
+ }
97
+ .rn-nav-controls {
98
+ display: flex;
99
+ gap: 0.5rem;
100
+ align-items: center;
101
+ }
102
+ .rn-btn {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ padding: 8px 16px;
107
+ border-radius: 6px;
108
+ font-size: 0.875rem;
109
+ font-weight: 500;
110
+ cursor: pointer;
111
+ border: 1px solid #d1d5db;
112
+ background: #fff;
113
+ color: #374151;
114
+ transition: background 0.15s, border-color 0.15s;
115
+ }
116
+ .rn-btn:hover {
117
+ background: #f9fafb;
118
+ border-color: #9ca3af;
119
+ }
120
+ .rn-btn:disabled {
121
+ opacity: 0.5;
122
+ cursor: not-allowed;
123
+ }
124
+ .rn-btn-icon {
125
+ padding: 8px;
126
+ min-width: 36px;
127
+ }
128
+ .rn-calendar-body {
129
+ display: flex;
130
+ position: relative;
131
+ border: 1px solid #e5e7eb;
132
+ border-radius: 4px;
133
+ min-height: 200px;
134
+ overflow: hidden;
135
+ }
136
+ .rn-loading-overlay {
137
+ position: absolute;
138
+ inset: 0;
139
+ background: rgba(255,255,255,0.8);
140
+ display: flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ z-index: 100;
144
+ font-weight: 600;
145
+ color: #6b7280;
146
+ }
147
+ .rn-resource-sidebar {
148
+ width: 140px;
149
+ flex-shrink: 0;
150
+ background: #f9fafb;
151
+ border-right: 1px solid #e5e7eb;
152
+ margin-top: 40px;
153
+ }
154
+ .rn-resource-row {
155
+ display: flex;
156
+ align-items: center;
157
+ height: 60px;
158
+ padding: 0 1rem;
159
+ border-bottom: 1px solid #e5e7eb;
160
+ font-size: 0.875rem;
161
+ font-weight: 500;
162
+ white-space: nowrap;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ }
166
+ .rn-chart {
167
+ flex: 1;
168
+ overflow-x: auto;
169
+ }
170
+ .rn-hour-labels {
171
+ display: flex;
172
+ height: 40px;
173
+ background: #f3f4f6;
174
+ border-bottom: 1px solid #e5e7eb;
175
+ }
176
+ .rn-hour-cell {
177
+ flex: 1;
178
+ min-width: 80px;
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ font-size: 0.75rem;
183
+ color: #4b5563;
184
+ border-right: 1px solid #e5e7eb;
185
+ }
186
+ .rn-hour-cell.is-current {
187
+ background: #dbeafe;
188
+ font-weight: 600;
189
+ color: #1d4ed8;
190
+ }
191
+ .rn-timeline {
192
+ position: relative;
193
+ }
194
+ .rn-timeline-row {
195
+ display: flex;
196
+ height: 60px;
197
+ position: relative;
198
+ }
199
+ .rn-timeline-cell {
200
+ flex: 1;
201
+ min-width: 80px;
202
+ border-right: 1px solid #e5e7eb;
203
+ border-bottom: 1px solid #e5e7eb;
204
+ }
205
+ .rn-timeline-cell.is-current {
206
+ background: #eff6ff;
207
+ }
208
+ .rn-event {
209
+ position: absolute;
210
+ top: 6px;
211
+ height: calc(100% - 12px);
212
+ background: #3b82f6;
213
+ color: #fff;
214
+ padding: 4px 8px;
215
+ border-radius: 4px;
216
+ font-size: 0.75rem;
217
+ overflow: hidden;
218
+ cursor: pointer;
219
+ z-index: 10;
220
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
221
+ transition: transform 0.1s;
222
+ }
223
+ .rn-event:hover {
224
+ transform: scale(1.02);
225
+ z-index: 20;
226
+ }
227
+ .rn-event.status-cancelled {
228
+ background: #ef4444;
229
+ }
230
+ .rn-event.status-confirmed {
231
+ background: #10b981;
232
+ }
233
+ .rn-event-time {
234
+ font-weight: 700;
235
+ }
236
+ .rn-event-label {
237
+ white-space: nowrap;
238
+ overflow: hidden;
239
+ text-overflow: ellipsis;
240
+ }
241
+ .rn-event-guests {
242
+ font-size: 0.7rem;
243
+ opacity: 0.9;
244
+ }
245
+ .rn-now-indicator {
246
+ position: absolute;
247
+ top: 0;
248
+ bottom: 0;
249
+ width: 2px;
250
+ background: #ef4444;
251
+ z-index: 30;
252
+ pointer-events: none;
253
+ }
254
+ .rn-now-indicator::before {
255
+ content: '';
256
+ position: absolute;
257
+ top: 0;
258
+ left: -4px;
259
+ width: 10px;
260
+ height: 10px;
261
+ background: #ef4444;
262
+ border-radius: 50%;
263
+ }
264
+ .rn-view-switcher {
265
+ display: flex;
266
+ gap: 0;
267
+ border: 1px solid #d1d5db;
268
+ border-radius: 6px;
269
+ overflow: hidden;
270
+ }
271
+ .rn-view-btn {
272
+ padding: 6px 12px;
273
+ font-size: 0.8rem;
274
+ font-weight: 500;
275
+ cursor: pointer;
276
+ border: none;
277
+ background: #fff;
278
+ color: #374151;
279
+ transition: background 0.15s;
280
+ border-right: 1px solid #d1d5db;
281
+ }
282
+ .rn-view-btn:last-child {
283
+ border-right: none;
284
+ }
285
+ .rn-view-btn:hover {
286
+ background: #f3f4f6;
287
+ }
288
+ .rn-view-btn.is-active {
289
+ background: #3b82f6;
290
+ color: #fff;
291
+ }
292
+ .rn-day-header {
293
+ display: flex;
294
+ background: #f3f4f6;
295
+ border-bottom: 1px solid #e5e7eb;
296
+ }
297
+ .rn-day-column-header {
298
+ flex: 1;
299
+ text-align: center;
300
+ padding: 8px 4px;
301
+ font-size: 0.75rem;
302
+ font-weight: 600;
303
+ color: #374151;
304
+ border-right: 1px solid #e5e7eb;
305
+ }
306
+ .rn-day-column-header:last-child {
307
+ border-right: none;
308
+ }
309
+ .rn-day-column-header.is-today {
310
+ background: #dbeafe;
311
+ color: #1d4ed8;
312
+ }
313
+ .rn-multi-day-timeline {
314
+ display: flex;
315
+ }
316
+ .rn-day-column {
317
+ flex: 1;
318
+ border-right: 1px solid #e5e7eb;
319
+ }
320
+ .rn-day-column:last-child {
321
+ border-right: none;
322
+ }
323
+ `;
324
+
325
+ function injectStyles() {
326
+ if (document.getElementById('rn-timeline-calendar-styles')) return;
327
+ const style = document.createElement('style');
328
+ style.id = 'rn-timeline-calendar-styles';
329
+ style.textContent = CALENDAR_STYLES;
330
+ document.head.appendChild(style);
331
+ }
332
+
333
+ // ============ MAIN CLASS ============
334
+ class ResourceNest {
335
+ constructor(selector, options = {}) {
336
+ this.root = typeof selector === 'string' ? document.querySelector(selector) : selector;
337
+ if (!this.root) {
338
+ throw new Error(`ResourceNest: Element "${selector}" not found`);
339
+ }
340
+
341
+ this.options = { ...DEFAULTS, ...options };
342
+ this.currentDate = startOfDay(new Date());
343
+ this.events = this._normalizeEvents(this.options.events);
344
+ this.resources = this.options.resources || [];
345
+ this.loading = false;
346
+ this.view = this.options.view || 'day';
347
+
348
+ injectStyles();
349
+ this._render();
350
+ }
351
+
352
+ // ============ PUBLIC API ============
353
+
354
+ /** Set events and re-render */
355
+ setEvents(events) {
356
+ this.events = this._normalizeEvents(events);
357
+ this._renderTimeline();
358
+ return this;
359
+ }
360
+
361
+ /** Set resources and re-render */
362
+ setResources(resources) {
363
+ this.resources = resources || [];
364
+ this._render();
365
+ return this;
366
+ }
367
+
368
+ /** Navigate to a specific date */
369
+ setDate(date) {
370
+ this.currentDate = startOfDay(new Date(date));
371
+ this._render();
372
+ this._emitDateChange();
373
+ return this;
374
+ }
375
+
376
+ /** Navigate to today */
377
+ goToToday() {
378
+ this.currentDate = startOfDay(new Date());
379
+ this._render();
380
+ this._emitDateChange();
381
+ return this;
382
+ }
383
+
384
+ /** Navigate to next day */
385
+ nextDay() {
386
+ const days = this._getViewDays();
387
+ this.currentDate = addDays(this.currentDate, days);
388
+ this._render();
389
+ this._emitDateChange();
390
+ return this;
391
+ }
392
+
393
+ /** Navigate to previous day */
394
+ prevDay() {
395
+ const days = this._getViewDays();
396
+ this.currentDate = addDays(this.currentDate, -days);
397
+ this._render();
398
+ this._emitDateChange();
399
+ return this;
400
+ }
401
+
402
+ /** Set view mode: 'day', '3days', 'week' */
403
+ setView(view) {
404
+ if (['day', '3days', 'week'].includes(view)) {
405
+ this.view = view;
406
+ this._render();
407
+ this._emitViewChange();
408
+ }
409
+ return this;
410
+ }
411
+
412
+ /** Get current view mode */
413
+ getView() {
414
+ return this.view;
415
+ }
416
+
417
+ /** Show loading overlay */
418
+ setLoading(loading) {
419
+ this.loading = loading;
420
+ const overlay = this.root.querySelector('.rn-loading-overlay');
421
+ if (overlay) {
422
+ overlay.style.display = loading ? 'flex' : 'none';
423
+ }
424
+ return this;
425
+ }
426
+
427
+ /** Refresh the calendar */
428
+ refresh() {
429
+ this._render();
430
+ return this;
431
+ }
432
+
433
+ /** Scroll to current hour (if today is visible) */
434
+ scrollToNow() {
435
+ const chart = this.root.querySelector('.rn-chart');
436
+ const now = new Date();
437
+ if (!chart) return this;
438
+
439
+ const visibleDates = this._getVisibleDates();
440
+ const todayIndex = visibleDates.findIndex(d => isSameDay(d, now));
441
+
442
+ if (todayIndex === -1) return this; // Today not visible
443
+
444
+ const { startHour, endHour } = this.options;
445
+ const hoursPerDay = endHour - startHour;
446
+ const totalHours = hoursPerDay * visibleDates.length;
447
+
448
+ // Calculate position: day offset + hour offset within day
449
+ const hourInDay = now.getHours() - startHour;
450
+ const totalHourOffset = (todayIndex * hoursPerDay) + hourInDay;
451
+ const progress = totalHourOffset / totalHours;
452
+
453
+ chart.scrollLeft = chart.scrollWidth * Math.max(0, Math.min(1, progress)) - chart.clientWidth / 2;
454
+ return this;
455
+ }
456
+
457
+ /** Get current date */
458
+ getDate() {
459
+ return new Date(this.currentDate);
460
+ }
461
+
462
+ /** Destroy the calendar */
463
+ destroy() {
464
+ this.root.innerHTML = '';
465
+ this.root.classList.remove('rn-timeline-calendar');
466
+ }
467
+
468
+ /** Set callback for event click */
469
+ onEventClick(callback) {
470
+ this.options.onEventClick = callback;
471
+ return this;
472
+ }
473
+
474
+ /** Set callback for time slot click */
475
+ onTimeClick(callback) {
476
+ this.options.onTimeClick = callback;
477
+ return this;
478
+ }
479
+
480
+ /** Set callback for date change */
481
+ onDateChange(callback) {
482
+ this.options.onDateChange = callback;
483
+ return this;
484
+ }
485
+
486
+ /** Set callback for view change */
487
+ onViewChange(callback) {
488
+ this.options.onViewChange = callback;
489
+ return this;
490
+ }
491
+
492
+ // ============ INTERNAL ============
493
+
494
+ _getViewDays() {
495
+ switch (this.view) {
496
+ case '3days': return 3;
497
+ case 'week': return 7;
498
+ default: return 1;
499
+ }
500
+ }
501
+
502
+ _getVisibleDates() {
503
+ const days = this._getViewDays();
504
+ const dates = [];
505
+ for (let i = 0; i < days; i++) {
506
+ dates.push(addDays(this.currentDate, i));
507
+ }
508
+ return dates;
509
+ }
510
+
511
+ _normalizeEvents(events) {
512
+ return (events || []).map(e => ({
513
+ id: e.id,
514
+ resourceId: e.resourceId || e.serviceResourceId,
515
+ from: new Date(e.from),
516
+ to: new Date(e.to),
517
+ label: e.label || e.title || e.code || '',
518
+ guestCount: e.guestCount || e.guests || 0,
519
+ status: e.status || null,
520
+ data: e, // Original data
521
+ }));
522
+ }
523
+
524
+ _getHours() {
525
+ const { startHour, endHour } = this.options;
526
+ const hours = [];
527
+ const now = new Date();
528
+ for (let h = startHour; h < endHour; h++) {
529
+ hours.push({
530
+ hour: h,
531
+ label: `${h.toString().padStart(2, '0')}:00`,
532
+ isCurrent: isSameDay(now, this.currentDate) && now.getHours() === h,
533
+ });
534
+ }
535
+ return hours;
536
+ }
537
+
538
+ _getDayRange() {
539
+ const { startHour, endHour } = this.options;
540
+ const days = this._getViewDays();
541
+
542
+ const from = new Date(this.currentDate);
543
+ from.setHours(startHour, 0, 0, 0);
544
+
545
+ const to = new Date(this.currentDate);
546
+ to.setDate(to.getDate() + days - 1); // Add days for multi-day view
547
+ to.setHours(endHour, 0, 0, 0);
548
+
549
+ return { from, to, rangeMs: to - from };
550
+ }
551
+
552
+ _getEventsForResource(resourceId) {
553
+ const { from, to } = this._getDayRange();
554
+ return this.events.filter(e => {
555
+ if (e.resourceId !== resourceId) return false;
556
+ return !(e.to <= from || e.from >= to);
557
+ });
558
+ }
559
+
560
+ _render() {
561
+ const { showNavigation, showViewSwitcher, todayLabel, loadingLabel, dateFormat, viewLabels } = this.options;
562
+ const hours = this._getHours();
563
+ const visibleDates = this._getVisibleDates();
564
+ const isMultiDay = visibleDates.length > 1;
565
+
566
+ // Format date display
567
+ let dateDisplay;
568
+ if (isMultiDay) {
569
+ const firstDate = visibleDates[0];
570
+ const lastDate = visibleDates[visibleDates.length - 1];
571
+ dateDisplay = `${formatDate(firstDate, dateFormat)} - ${formatDate(lastDate, dateFormat)}`;
572
+ } else {
573
+ dateDisplay = formatDate(this.currentDate, dateFormat);
574
+ }
575
+
576
+ // Generate hour cells for each day
577
+ const allHourCells = visibleDates.flatMap((date, dayIndex) =>
578
+ hours.map(h => ({
579
+ ...h,
580
+ dayIndex,
581
+ date,
582
+ isCurrent: isSameDay(date, new Date()) && new Date().getHours() === h.hour,
583
+ }))
584
+ );
585
+
586
+ this.root.classList.add('rn-timeline-calendar');
587
+ this.root.innerHTML = `
588
+ <div class="rn-calendar-header">
589
+ <div class="rn-date-display">${dateDisplay}</div>
590
+ <div class="rn-nav-controls">
591
+ ${showViewSwitcher ? `
592
+ <div class="rn-view-switcher">
593
+ <button class="rn-view-btn ${this.view === 'day' ? 'is-active' : ''}" data-view="day">${viewLabels.day}</button>
594
+ <button class="rn-view-btn ${this.view === '3days' ? 'is-active' : ''}" data-view="3days">${viewLabels['3days']}</button>
595
+ <button class="rn-view-btn ${this.view === 'week' ? 'is-active' : ''}" data-view="week">${viewLabels.week}</button>
596
+ </div>
597
+ ` : ''}
598
+ ${showNavigation ? `
599
+ <button class="rn-btn" data-action="today">${todayLabel}</button>
600
+ <button class="rn-btn rn-btn-icon" data-action="prev">&#8249;</button>
601
+ <button class="rn-btn rn-btn-icon" data-action="next">&#8250;</button>
602
+ ` : ''}
603
+ </div>
604
+ </div>
605
+ <div class="rn-calendar-body">
606
+ <div class="rn-loading-overlay" style="display:${this.loading ? 'flex' : 'none'}">${loadingLabel}</div>
607
+ <div class="rn-resource-sidebar">
608
+ ${isMultiDay ? '<div class="rn-resource-row" style="height:40px;"></div>' : ''}
609
+ ${this.resources.map(r => `
610
+ <div class="rn-resource-row" data-resource-id="${r.id || r.Id}">${r.name || r.Name || r.title || '—'}</div>
611
+ `).join('')}
612
+ </div>
613
+ <div class="rn-chart">
614
+ ${isMultiDay ? this._renderMultiDayHeader(visibleDates, hours.length) : ''}
615
+ <div class="rn-hour-labels">
616
+ ${allHourCells.map(h => `
617
+ <div class="rn-hour-cell ${h.isCurrent ? 'is-current' : ''}">${h.label}</div>
618
+ `).join('')}
619
+ </div>
620
+ <div class="rn-timeline" data-days="${visibleDates.length}" data-hours="${hours.length}">
621
+ ${this.resources.map(r => `
622
+ <div class="rn-timeline-row" data-resource-id="${r.id || r.Id}">
623
+ ${allHourCells.map(h => `
624
+ <div class="rn-timeline-cell ${h.isCurrent ? 'is-current' : ''}" data-day="${h.dayIndex}"></div>
625
+ `).join('')}
626
+ </div>
627
+ `).join('')}
628
+ </div>
629
+ </div>
630
+ </div>
631
+ `;
632
+
633
+ this._bindEvents();
634
+ this._renderTimeline();
635
+ this.scrollToNow();
636
+ }
637
+
638
+ _renderMultiDayHeader(dates, hoursPerDay) {
639
+ const now = new Date();
640
+ const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
641
+ return `
642
+ <div class="rn-day-header">
643
+ ${dates.map(date => {
644
+ const isToday = isSameDay(date, now);
645
+ const dayName = dayNames[date.getDay()];
646
+ const dayNum = date.getDate();
647
+ return `<div class="rn-day-column-header ${isToday ? 'is-today' : ''}" style="flex: ${hoursPerDay};">${dayName} ${dayNum}</div>`;
648
+ }).join('')}
649
+ </div>
650
+ `;
651
+ }
652
+
653
+ _renderTimeline() {
654
+ const timeline = this.root.querySelector('.rn-timeline');
655
+ if (!timeline) return;
656
+
657
+ // Remove existing events and indicator
658
+ timeline.querySelectorAll('.rn-event, .rn-now-indicator').forEach(el => el.remove());
659
+
660
+ const visibleDates = this._getVisibleDates();
661
+ const { startHour, endHour } = this.options;
662
+ const hoursPerDay = endHour - startHour;
663
+ const totalHours = hoursPerDay * visibleDates.length;
664
+ const timelineWidth = timeline.scrollWidth;
665
+
666
+ // Render events for each resource
667
+ this.resources.forEach((resource) => {
668
+ const resourceId = resource.id || resource.Id;
669
+ const row = timeline.querySelector(`.rn-timeline-row[data-resource-id="${resourceId}"]`);
670
+ if (!row) return;
671
+
672
+ const events = this._getEventsForResource(resourceId);
673
+ events.forEach(event => {
674
+ // Find which day(s) this event falls on
675
+ visibleDates.forEach((date, dayIndex) => {
676
+ const dayStart = new Date(date);
677
+ dayStart.setHours(startHour, 0, 0, 0);
678
+ const dayEnd = new Date(date);
679
+ dayEnd.setHours(endHour, 0, 0, 0);
680
+
681
+ // Check if event overlaps with this day's visible hours
682
+ if (event.to <= dayStart || event.from >= dayEnd) return;
683
+
684
+ // Clamp event to visible hours of this day
685
+ const eventStart = event.from < dayStart ? dayStart : event.from;
686
+ const eventEnd = event.to > dayEnd ? dayEnd : event.to;
687
+
688
+ // Calculate position relative to entire timeline
689
+ const hourOffsetInDay = (eventStart.getHours() - startHour) + (eventStart.getMinutes() / 60);
690
+ const totalHourOffset = (dayIndex * hoursPerDay) + hourOffsetInDay;
691
+
692
+ const durationHours = (eventEnd - eventStart) / (1000 * 60 * 60);
693
+
694
+ const left = (timelineWidth / totalHours) * totalHourOffset;
695
+ const width = Math.max(40, (timelineWidth / totalHours) * durationHours);
696
+
697
+ const eventEl = document.createElement('div');
698
+ eventEl.className = 'rn-event';
699
+ if (event.status === 'Cancelled' || event.status === 'cancelled') {
700
+ eventEl.classList.add('status-cancelled');
701
+ }
702
+ if (event.status === 'Confirmed' || event.status === 'confirmed') {
703
+ eventEl.classList.add('status-confirmed');
704
+ }
705
+
706
+ eventEl.style.left = `${left}px`;
707
+ eventEl.style.width = `${width}px`;
708
+ eventEl.title = event.label;
709
+ eventEl.dataset.eventId = event.id;
710
+
711
+ eventEl.innerHTML = `
712
+ <div class="rn-event-time">${formatTime(event.from)}</div>
713
+ <div class="rn-event-label">${event.label}</div>
714
+ ${event.guestCount ? `<div class="rn-event-guests">${event.guestCount} people</div>` : ''}
715
+ `;
716
+
717
+ if (this.options.eventClickable) {
718
+ eventEl.addEventListener('click', (e) => {
719
+ e.stopPropagation();
720
+ if (typeof this.options.onEventClick === 'function') {
721
+ this.options.onEventClick(event.data, event);
722
+ }
723
+ });
724
+ }
725
+
726
+ row.appendChild(eventEl);
727
+ });
728
+ });
729
+ });
730
+
731
+ // Now indicator
732
+ if (this.options.showNowIndicator) {
733
+ const now = new Date();
734
+ const visibleDates = this._getVisibleDates();
735
+ const todayIndex = visibleDates.findIndex(d => isSameDay(d, now));
736
+
737
+ if (todayIndex !== -1 && now.getHours() >= this.options.startHour && now.getHours() < this.options.endHour) {
738
+ const hoursPerDay = this.options.endHour - this.options.startHour;
739
+ const totalHours = hoursPerDay * visibleDates.length;
740
+
741
+ const hourInDay = now.getHours() - this.options.startHour + (now.getMinutes() / 60);
742
+ const totalHourOffset = (todayIndex * hoursPerDay) + hourInDay;
743
+ const left = (timelineWidth / totalHours) * totalHourOffset;
744
+
745
+ const indicator = document.createElement('div');
746
+ indicator.className = 'rn-now-indicator';
747
+ indicator.style.left = `${left}px`;
748
+ timeline.appendChild(indicator);
749
+ }
750
+ }
751
+ }
752
+
753
+ _bindEvents() {
754
+ // Navigation buttons
755
+ const todayBtn = this.root.querySelector('[data-action="today"]');
756
+ const prevBtn = this.root.querySelector('[data-action="prev"]');
757
+ const nextBtn = this.root.querySelector('[data-action="next"]');
758
+
759
+ if (todayBtn) todayBtn.addEventListener('click', () => this.goToToday());
760
+ if (prevBtn) prevBtn.addEventListener('click', () => this.prevDay());
761
+ if (nextBtn) nextBtn.addEventListener('click', () => this.nextDay());
762
+
763
+ // View switcher buttons
764
+ this.root.querySelectorAll('[data-view]').forEach(btn => {
765
+ btn.addEventListener('click', () => {
766
+ const view = btn.dataset.view;
767
+ this.setView(view);
768
+ });
769
+ });
770
+
771
+ // Timeline click
772
+ if (this.options.timeClickable) {
773
+ const timeline = this.root.querySelector('.rn-timeline');
774
+ if (timeline) {
775
+ timeline.addEventListener('click', (e) => this._handleTimelineClick(e));
776
+ }
777
+ }
778
+ }
779
+
780
+ _handleTimelineClick(e) {
781
+ if (e.target.closest('.rn-event')) return;
782
+
783
+ const timeline = e.currentTarget;
784
+ const rect = timeline.getBoundingClientRect();
785
+ const clickX = e.clientX - rect.left + timeline.scrollLeft;
786
+ const clickY = e.clientY - rect.top;
787
+
788
+ const visibleDates = this._getVisibleDates();
789
+ const { startHour, endHour } = this.options;
790
+ const hoursPerDay = endHour - startHour;
791
+ const totalHours = hoursPerDay * visibleDates.length;
792
+ const timelineWidth = timeline.scrollWidth;
793
+
794
+ // Calculate which day and hour was clicked
795
+ const totalHourOffset = (clickX / timelineWidth) * totalHours;
796
+ const dayIndex = Math.floor(totalHourOffset / hoursPerDay);
797
+ const hourInDay = totalHourOffset % hoursPerDay;
798
+
799
+ const clickedDate = visibleDates[dayIndex] || visibleDates[0];
800
+ let clickedTime = new Date(clickedDate);
801
+ clickedTime.setHours(startHour + Math.floor(hourInDay), (hourInDay % 1) * 60, 0, 0);
802
+ clickedTime = roundToMinutes(clickedTime, 5);
803
+
804
+ // Calculate clicked resource
805
+ const rowHeight = 60;
806
+ const rowIndex = Math.floor(clickY / rowHeight);
807
+ const resource = this.resources[rowIndex];
808
+
809
+ if (resource && typeof this.options.onTimeClick === 'function') {
810
+ this.options.onTimeClick({
811
+ time: clickedTime.toISOString(),
812
+ date: clickedTime,
813
+ resource: resource,
814
+ });
815
+ }
816
+ }
817
+
818
+ _emitDateChange() {
819
+ if (typeof this.options.onDateChange === 'function') {
820
+ // Format date locally without UTC conversion
821
+ const year = this.currentDate.getFullYear();
822
+ const month = String(this.currentDate.getMonth() + 1).padStart(2, '0');
823
+ const day = String(this.currentDate.getDate()).padStart(2, '0');
824
+ const dateStr = `${year}-${month}-${day}`;
825
+
826
+ this.options.onDateChange(dateStr, this.currentDate);
827
+ }
828
+ }
829
+
830
+ _emitViewChange() {
831
+ if (typeof this.options.onViewChange === 'function') {
832
+ this.options.onViewChange(this.view);
833
+ }
834
+ }
835
+ }
836
+
837
+ // ============ FACTORY FUNCTION ============
838
+ function createResourceNest(selector, options = {}) {
839
+ return new ResourceNest(selector, options);
840
+ }
841
+
842
+ // ============ EXPORTS ============
843
+ export { ResourceNest, createResourceNest };
844
+ export default createResourceNest;
845
+