@intinyagroup/calendar 0.1.1-alpha.8 → 0.1.2

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,1418 @@
1
+ <script lang="ts">
2
+ import {
3
+ ChevronLeft,
4
+ ChevronRight,
5
+ Calendar as CalendarIcon,
6
+ CalendarDays,
7
+ Clock,
8
+ Plus,
9
+ X,
10
+ } from "lucide-svelte";
11
+ import { Button } from "@intinyagroup/ui";
12
+ import { cn } from "@intinyagroup/ui/utils";
13
+ import {
14
+ addMonths,
15
+ subMonths,
16
+ addWeeks,
17
+ subWeeks,
18
+ addDays,
19
+ subDays,
20
+ startOfMonth,
21
+ endOfMonth,
22
+ startOfWeek,
23
+ endOfWeek,
24
+ eachDayOfInterval,
25
+ isSameDay,
26
+ isSameMonth,
27
+ format,
28
+ } from "date-fns";
29
+ import { id as localeId, enUS as localeEn } from "date-fns/locale";
30
+ import {
31
+ expandRecurringEvents,
32
+ type CalendarRecurrence,
33
+ type CalendarResource,
34
+ } from "./recurrence";
35
+
36
+ export type CalendarView = "month" | "week" | "day" | "resourceTimeline";
37
+
38
+ export type CalendarEvent = {
39
+ id: string;
40
+ title: string;
41
+ start: Date;
42
+ end: Date;
43
+ color?: string;
44
+ description?: string;
45
+ allDay?: boolean;
46
+ /** Optional minimal recurrence. See CalendarRecurrence for supported semantics. */
47
+ recurrence?: CalendarRecurrence;
48
+ /** Resource row used by resourceTimeline view. */
49
+ resourceId?: string;
50
+ };
51
+
52
+ let {
53
+ events = $bindable([]),
54
+ view = $bindable("month" as CalendarView),
55
+ currentDate = $bindable(new Date()),
56
+ locale = "en-US",
57
+ timeZone,
58
+ firstDayOfWeek = 0, // 0 = Sunday, 1 = Monday
59
+ enableEventModal = true,
60
+ resources = [],
61
+ resourceTimelineStartHour = 0,
62
+ resourceTimelineEndHour = 24,
63
+ class: className,
64
+ onEventClick,
65
+ onDateClick,
66
+ onAddEvent,
67
+ onDragCreate,
68
+ onEventReschedule,
69
+ }: {
70
+ events?: CalendarEvent[];
71
+ view?: CalendarView;
72
+ currentDate?: Date;
73
+ locale?: string;
74
+ timeZone?: string;
75
+ firstDayOfWeek?: number;
76
+ enableEventModal?: boolean;
77
+ resources?: CalendarResource[];
78
+ resourceTimelineStartHour?: number;
79
+ resourceTimelineEndHour?: number;
80
+ class?: string;
81
+ onEventClick?: (event: CalendarEvent) => void;
82
+ onDateClick?: (date: Date) => void;
83
+ onAddEvent?: (newEvent: CalendarEvent) => void;
84
+ onEventReschedule?: (detail: {
85
+ event: CalendarEvent;
86
+ newStart: Date;
87
+ newEnd: Date;
88
+ }) => void;
89
+ onEventResize?: (detail: {
90
+ event: CalendarEvent;
91
+ newStart: Date;
92
+ newEnd: Date;
93
+ }) => void;
94
+ onDragCreate?: (detail: { start: Date; end: Date }) => void;
95
+ } = $props();
96
+
97
+ let draggedEventId = $state<string | null>(null);
98
+ let resizingEventId = $state<string | null>(null);
99
+ let resizeStartY = $state<number>(0);
100
+ let resizeStartDuration = $state<number>(0);
101
+ // Drag-to-create state
102
+ let dragCreateStart = $state<{
103
+ date: Date;
104
+ hour: number;
105
+ minute: number;
106
+ } | null>(null);
107
+ let dragCreateEnd = $state<{
108
+ date: Date;
109
+ hour: number;
110
+ minute: number;
111
+ } | null>(null);
112
+ let isDragCreating = $state(false);
113
+ let dragCreateColumn = $state<Date | null>(null);
114
+ // Mobile detection
115
+ let isMobile = $state(
116
+ typeof window !== "undefined" && typeof window.matchMedia === "function"
117
+ ? window.matchMedia("(max-width: 767px)").matches
118
+ : false,
119
+ );
120
+ $effect(() => {
121
+ if (
122
+ typeof window === "undefined" ||
123
+ typeof window.matchMedia !== "function"
124
+ )
125
+ return;
126
+ const mq = window.matchMedia("(max-width: 767px)");
127
+ isMobile = mq.matches;
128
+ const handler = (e: MediaQueryListEvent) => {
129
+ isMobile = e.matches;
130
+ };
131
+ mq.addEventListener?.("change", handler);
132
+ return () => mq.removeEventListener?.("change", handler);
133
+ });
134
+
135
+ // Mobile week: show only current day ± 1
136
+ const mobileWeekDays = $derived([
137
+ subDays(currentDate, 1),
138
+ currentDate,
139
+ addDays(currentDate, 1),
140
+ ]);
141
+
142
+ function isMultiDayOrAllDay(ev: CalendarEvent): boolean {
143
+ if (ev.allDay) return true;
144
+ const s = new Date(ev.start);
145
+ const e = new Date(ev.end);
146
+ return !isSameDay(s, e) || e.getTime() - s.getTime() >= 24 * 60 * 60 * 1000;
147
+ }
148
+
149
+ function handleEventDrop(targetDate: Date, targetHour?: number) {
150
+ if (!draggedEventId) return;
151
+ const ev = events.find((e) => e.id === draggedEventId);
152
+ if (!ev) return;
153
+
154
+ const origDurationMs =
155
+ new Date(ev.end).getTime() - new Date(ev.start).getTime();
156
+ const newStart = new Date(targetDate);
157
+ if (targetHour !== undefined) {
158
+ newStart.setHours(targetHour, 0, 0, 0);
159
+ } else {
160
+ newStart.setHours(
161
+ new Date(ev.start).getHours(),
162
+ new Date(ev.start).getMinutes(),
163
+ 0,
164
+ 0,
165
+ );
166
+ }
167
+ const newEnd = new Date(newStart.getTime() + origDurationMs);
168
+
169
+ events = events.map((e) =>
170
+ e.id === draggedEventId ? { ...e, start: newStart, end: newEnd } : e,
171
+ );
172
+
173
+ onEventReschedule?.({ event: ev, newStart, newEnd });
174
+ draggedEventId = null;
175
+ }
176
+
177
+ function handleResizeStart(e: MouseEvent, ev: CalendarEvent) {
178
+ e.stopPropagation();
179
+ e.preventDefault();
180
+ resizingEventId = ev.id;
181
+ resizeStartY = e.clientY;
182
+ resizeStartDuration =
183
+ new Date(ev.end).getTime() - new Date(ev.start).getTime();
184
+
185
+ function onMouseMove(moveEvent: MouseEvent) {
186
+ if (!resizingEventId) return;
187
+ const deltaY = moveEvent.clientY - resizeStartY;
188
+ // 56px per hour => deltaY / 56 hours
189
+ const deltaHours = deltaY / 56;
190
+ // Snap to 15 minute increments (0.25h = 15 * 60 * 1000 ms)
191
+ const snapIntervalMs = 15 * 60 * 1000;
192
+ const rawDeltaMs = deltaHours * 60 * 60 * 1000;
193
+ const snappedDeltaMs =
194
+ Math.round(rawDeltaMs / snapIntervalMs) * snapIntervalMs;
195
+ const newDurationMs = Math.max(
196
+ 15 * 60 * 1000,
197
+ resizeStartDuration + snappedDeltaMs,
198
+ );
199
+
200
+ events = events.map((item) => {
201
+ if (item.id === resizingEventId) {
202
+ const s = new Date(item.start);
203
+ return { ...item, end: new Date(s.getTime() + newDurationMs) };
204
+ }
205
+ return item;
206
+ });
207
+ }
208
+
209
+ function onMouseUp() {
210
+ if (resizingEventId) {
211
+ const finished = events.find((item) => item.id === resizingEventId);
212
+ if (finished) {
213
+ onEventResize?.({
214
+ event: finished,
215
+ newStart: new Date(finished.start),
216
+ newEnd: new Date(finished.end),
217
+ });
218
+ }
219
+ }
220
+ resizingEventId = null;
221
+ window.removeEventListener("mousemove", onMouseMove);
222
+ window.removeEventListener("mouseup", onMouseUp);
223
+ }
224
+
225
+ window.addEventListener("mousemove", onMouseMove);
226
+ window.addEventListener("mouseup", onMouseUp);
227
+ }
228
+ // Drag-to-create: snap to 15-minute increments
229
+ function snapTo15Minutes(minutes: number): number {
230
+ return Math.round(minutes / 15) * 15;
231
+ }
232
+
233
+ function handleDragCreateMouseDown(
234
+ e: MouseEvent,
235
+ date: Date,
236
+ hour: number,
237
+ containerEl: HTMLElement,
238
+ ) {
239
+ // Only left click
240
+ if (e.button !== 0) return;
241
+ // Don't start drag-create if already dragging an event or resizing
242
+ if (draggedEventId || resizingEventId) return;
243
+ // Don't start if clicking on an event element
244
+ const target = e.target as HTMLElement;
245
+ if (target.closest('[role="button"]') || target.closest(".group\\/event"))
246
+ return;
247
+
248
+ e.preventDefault();
249
+ e.stopPropagation();
250
+
251
+ const rect = containerEl.getBoundingClientRect();
252
+ const y = e.clientY - rect.top;
253
+ const hourHeight = containerEl.id === "week-day-col" ? 56 : 64;
254
+ const minutesInHour = (y / hourHeight) * 60;
255
+ const minute = snapTo15Minutes(minutesInHour);
256
+ const clampedMinute = Math.min(45, Math.max(0, minute));
257
+
258
+ dragCreateStart = { date, hour, minute: clampedMinute };
259
+ dragCreateEnd = { date, hour, minute: clampedMinute + 15 };
260
+ dragCreateColumn = date;
261
+ isDragCreating = true;
262
+
263
+ function onMouseMove(moveEvent: MouseEvent) {
264
+ if (!isDragCreating || !dragCreateStart) return;
265
+ const moveY = moveEvent.clientY - rect.top;
266
+ const moveMinutesInHour = (moveY / hourHeight) * 60;
267
+ const snappedMinutes = snapTo15Minutes(moveMinutesInHour);
268
+ const clampedMinutes = Math.min(24 * 60, Math.max(0, snappedMinutes));
269
+
270
+ const totalStartMinutes =
271
+ dragCreateStart.hour * 60 + dragCreateStart.minute;
272
+ let totalEndMinutes = Math.max(totalStartMinutes + 15, clampedMinutes);
273
+ totalEndMinutes = Math.min(24 * 60, totalEndMinutes);
274
+
275
+ const endHour = Math.floor(totalEndMinutes / 60);
276
+ const endMinute = totalEndMinutes % 60;
277
+ dragCreateEnd = { date, hour: endHour, minute: endMinute };
278
+ }
279
+
280
+ function onMouseUp() {
281
+ if (isDragCreating && dragCreateStart && dragCreateEnd) {
282
+ const startMinutes = dragCreateStart.hour * 60 + dragCreateStart.minute;
283
+ const endMinutes = dragCreateEnd.hour * 60 + dragCreateEnd.minute;
284
+
285
+ if (endMinutes - startMinutes >= 15) {
286
+ const startDate = new Date(date);
287
+ startDate.setHours(
288
+ dragCreateStart.hour,
289
+ dragCreateStart.minute,
290
+ 0,
291
+ 0,
292
+ );
293
+ const endDate = new Date(date);
294
+ endDate.setHours(dragCreateEnd.hour, dragCreateEnd.minute, 0, 0);
295
+
296
+ onDragCreate?.({ start: startDate, end: endDate });
297
+
298
+ if (enableEventModal) {
299
+ newEventDate = new Date(date);
300
+ newEventStartTime = `${String(dragCreateStart.hour).padStart(2, "0")}:${String(dragCreateStart.minute).padStart(2, "0")}`;
301
+ newEventEndTime = `${String(dragCreateEnd.hour).padStart(2, "0")}:${String(dragCreateEnd.minute).padStart(2, "0")}`;
302
+ modalOpen = true;
303
+ }
304
+ }
305
+ }
306
+
307
+ isDragCreating = false;
308
+ dragCreateStart = null;
309
+ dragCreateEnd = null;
310
+ dragCreateColumn = null;
311
+ window.removeEventListener("mousemove", onMouseMove);
312
+ window.removeEventListener("mouseup", onMouseUp);
313
+ }
314
+
315
+ window.addEventListener("mousemove", onMouseMove);
316
+ window.addEventListener("mouseup", onMouseUp);
317
+ }
318
+ const visibleRange = $derived.by(() => {
319
+ if (view === "month") {
320
+ const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
321
+ const end = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
322
+ return { start, end };
323
+ }
324
+ if (view === "week") {
325
+ return {
326
+ start: startOfWeek(currentDate, { weekStartsOn }),
327
+ end: endOfWeek(currentDate, { weekStartsOn }),
328
+ };
329
+ }
330
+ return {
331
+ start: new Date(
332
+ currentDate.getFullYear(),
333
+ currentDate.getMonth(),
334
+ currentDate.getDate(),
335
+ ),
336
+ end: new Date(
337
+ currentDate.getFullYear(),
338
+ currentDate.getMonth(),
339
+ currentDate.getDate() + 1,
340
+ ),
341
+ };
342
+ });
343
+
344
+ // Recurrence expansion stays viewport-bounded, preserving existing event APIs.
345
+ const displayEvents = $derived(
346
+ expandRecurringEvents(events, visibleRange.start, visibleRange.end),
347
+ );
348
+
349
+ function getResourceEvents(resourceId: string): CalendarEvent[] {
350
+ return displayEvents.filter((event) => {
351
+ if (event.resourceId !== resourceId) return false;
352
+ const start = new Date(event.start);
353
+ const end = new Date(event.end);
354
+ const dayStart = new Date(
355
+ currentDate.getFullYear(),
356
+ currentDate.getMonth(),
357
+ currentDate.getDate(),
358
+ );
359
+ const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
360
+ return end >= dayStart && start <= dayEnd;
361
+ });
362
+ }
363
+
364
+ function timelinePosition(date: Date): number {
365
+ const start = Math.min(23, Math.max(0, resourceTimelineStartHour));
366
+ const end = Math.max(start + 1, Math.min(24, resourceTimelineEndHour));
367
+ const total = (end - start) * 60;
368
+ const local = getTimeInZone(date).hours * 60 + getTimeInZone(date).minutes;
369
+ return ((local - start * 60) / total) * 100;
370
+ }
371
+
372
+ function timelineWidth(event: CalendarEvent): number {
373
+ const start = Math.min(23, Math.max(0, resourceTimelineStartHour));
374
+ const end = Math.max(start + 1, Math.min(24, resourceTimelineEndHour));
375
+ const total = (end - start) * 60;
376
+ const begin = getTimeInZone(new Date(event.start));
377
+ const finish = getTimeInZone(new Date(event.end));
378
+ const duration = Math.max(
379
+ 15,
380
+ finish.hours * 60 + finish.minutes - (begin.hours * 60 + begin.minutes),
381
+ );
382
+ return (duration / total) * 100;
383
+ }
384
+
385
+ const timelineHours = $derived.by(() => {
386
+ const start = Math.min(23, Math.max(0, resourceTimelineStartHour));
387
+ const end = Math.max(start + 1, Math.min(24, resourceTimelineEndHour));
388
+ return Array.from({ length: end - start }, (_, index) => start + index);
389
+ });
390
+
391
+ type LayoutEvent = CalendarEvent & {
392
+ colIndex: number;
393
+ colTotal: number;
394
+ };
395
+
396
+ function computeOverlappingLayout(dayEvs: CalendarEvent[]): LayoutEvent[] {
397
+ // Sort events by start time, then longer events first
398
+ const sorted = [...dayEvs].sort((a, b) => {
399
+ const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
400
+ if (diff !== 0) return diff;
401
+ return (
402
+ new Date(b.end).getTime() -
403
+ new Date(b.start).getTime() -
404
+ (new Date(a.end).getTime() - new Date(a.start).getTime())
405
+ );
406
+ });
407
+
408
+ const columns: CalendarEvent[][] = [];
409
+ const layoutMap = new Map<string, { colIndex: number }>();
410
+
411
+ for (const ev of sorted) {
412
+ let placed = false;
413
+ for (let i = 0; i < columns.length; i++) {
414
+ const lastInCol = columns[i][columns[i].length - 1];
415
+ if (new Date(lastInCol.end).getTime() <= new Date(ev.start).getTime()) {
416
+ columns[i].push(ev);
417
+ layoutMap.set(ev.id, { colIndex: i });
418
+ placed = true;
419
+ break;
420
+ }
421
+ }
422
+ if (!placed) {
423
+ columns.push([ev]);
424
+ layoutMap.set(ev.id, { colIndex: columns.length - 1 });
425
+ }
426
+ }
427
+
428
+ // Determine overlapping groups
429
+ return sorted.map((ev) => {
430
+ const colInfo = layoutMap.get(ev.id) || { colIndex: 0 };
431
+ // Count how many columns overlap with this event
432
+ let overlappingCols = 1;
433
+ for (let c = 0; c < columns.length; c++) {
434
+ const hasOverlap = columns[c].some(
435
+ (other) =>
436
+ new Date(other.start).getTime() < new Date(ev.end).getTime() &&
437
+ new Date(other.end).getTime() > new Date(ev.start).getTime(),
438
+ );
439
+ if (hasOverlap && c + 1 > overlappingCols) {
440
+ overlappingCols = c + 1;
441
+ }
442
+ }
443
+
444
+ return {
445
+ ...ev,
446
+ colIndex: colInfo.colIndex,
447
+ colTotal: Math.max(1, overlappingCols),
448
+ };
449
+ });
450
+ }
451
+
452
+ const activeTimeZone = $derived(
453
+ timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
454
+ );
455
+
456
+ const activeDateFnsLocale = $derived(
457
+ locale.startsWith("id") ? localeId : localeEn,
458
+ );
459
+ const weekStartsOn = $derived(firstDayOfWeek as 0 | 1 | 2 | 3 | 4 | 5 | 6);
460
+
461
+ const hours = Array.from({ length: 24 }, (_, i) => i);
462
+ // Day names localized and rotated according to firstDayOfWeek via date-fns
463
+ const dayNames = $derived.by(() => {
464
+ const start = startOfWeek(currentDate, { weekStartsOn });
465
+ return Array.from({ length: 7 }, (_, i) => {
466
+ return format(addDays(start, i), "EEE", { locale: activeDateFnsLocale });
467
+ });
468
+ });
469
+
470
+ function isToday(d: Date): boolean {
471
+ return isSameDay(d, new Date());
472
+ }
473
+
474
+ function goPrev() {
475
+ if (view === "month") currentDate = subMonths(currentDate, 1);
476
+ else if (view === "week") currentDate = subWeeks(currentDate, 1);
477
+ else currentDate = subDays(currentDate, 1);
478
+ }
479
+
480
+ function goNext() {
481
+ if (view === "month") currentDate = addMonths(currentDate, 1);
482
+ else if (view === "week") currentDate = addWeeks(currentDate, 1);
483
+ else currentDate = addDays(currentDate, 1);
484
+ }
485
+
486
+ function goToday() {
487
+ currentDate = new Date();
488
+ }
489
+
490
+ // Month view calendar matrix using date-fns intervals
491
+ const monthDays = $derived.by(() => {
492
+ const monthStart = startOfMonth(currentDate);
493
+ const monthEnd = endOfMonth(currentDate);
494
+ const gridStart = startOfWeek(monthStart, { weekStartsOn });
495
+ const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
496
+
497
+ const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
498
+ return days.map((d) => ({
499
+ date: d,
500
+ currentMonth: isSameMonth(d, currentDate),
501
+ }));
502
+ });
503
+
504
+ // Week view 7 days using date-fns
505
+ const weekDays = $derived.by(() => {
506
+ const start = startOfWeek(currentDate, { weekStartsOn });
507
+ return Array.from({ length: 7 }, (_, i) => addDays(start, i));
508
+ });
509
+
510
+ function getEventsForDay(d: Date): CalendarEvent[] {
511
+ return displayEvents.filter((ev) => {
512
+ if (isSameDay(new Date(ev.start), d)) return true;
513
+ const s = new Date(ev.start);
514
+ const e = new Date(ev.end);
515
+ return d >= s && d <= e;
516
+ });
517
+ }
518
+
519
+ function getTimedEventsForDay(d: Date): CalendarEvent[] {
520
+ return displayEvents.filter(
521
+ (ev) => !isMultiDayOrAllDay(ev) && isSameDay(new Date(ev.start), d),
522
+ );
523
+ }
524
+
525
+ function getAllDayEventsForWeek(days: Date[]): CalendarEvent[] {
526
+ return displayEvents.filter((ev) => {
527
+ if (!isMultiDayOrAllDay(ev)) return false;
528
+ const s = new Date(ev.start);
529
+ const e = new Date(ev.end);
530
+ return days.some(
531
+ (d) => isSameDay(d, s) || isSameDay(d, e) || (d > s && d < e),
532
+ );
533
+ });
534
+ }
535
+
536
+ function getTimeInZone(date: Date): { hours: number; minutes: number } {
537
+ const parts = new Intl.DateTimeFormat("en-US", {
538
+ timeZone: activeTimeZone,
539
+ hour: "numeric",
540
+ minute: "numeric",
541
+ hour12: false,
542
+ }).formatToParts(date);
543
+
544
+ let hours = 0;
545
+ let minutes = 0;
546
+ for (const p of parts) {
547
+ if (p.type === "hour") hours = Number(p.value) % 24;
548
+ if (p.type === "minute") minutes = Number(p.value);
549
+ }
550
+ return { hours, minutes };
551
+ }
552
+
553
+ function formatTime(date: Date): string {
554
+ return new Intl.DateTimeFormat(locale, {
555
+ timeZone: activeTimeZone,
556
+ hour: "2-digit",
557
+ minute: "2-digit",
558
+ hour12: false,
559
+ }).format(date);
560
+ }
561
+
562
+ const titleHeader = $derived.by(() => {
563
+ if (view === "month") {
564
+ return format(currentDate, "MMMM yyyy", { locale: activeDateFnsLocale });
565
+ }
566
+ if (view === "week") {
567
+ const displayDays = isMobile ? mobileWeekDays : weekDays;
568
+ const start = displayDays[0];
569
+ const end = displayDays[displayDays.length - 1];
570
+ const sStr = format(start, "dd MMM", { locale: activeDateFnsLocale });
571
+ const eStr = format(end, "dd MMM yyyy", { locale: activeDateFnsLocale });
572
+ return `${sStr} - ${eStr}`;
573
+ }
574
+ return format(currentDate, "dd MMMM yyyy", { locale: activeDateFnsLocale });
575
+ });
576
+
577
+ // Event modal state
578
+ let modalOpen = $state(false);
579
+ let newEventTitle = $state("");
580
+ let newEventDate = $state(new Date());
581
+ let newEventStartTime = $state("09:00");
582
+ let newEventEndTime = $state("10:00");
583
+ let newEventColor = $state("#2563eb");
584
+ let newEventDescription = $state("");
585
+
586
+ const presetColors = [
587
+ "#2563eb",
588
+ "#7c3aed",
589
+ "#059669",
590
+ "#d97706",
591
+ "#dc2626",
592
+ "#0891b2",
593
+ "#4b5563",
594
+ ];
595
+
596
+ function openCreateModal(date: Date) {
597
+ if (!enableEventModal) return;
598
+ newEventDate = new Date(date);
599
+ newEventTitle = "";
600
+ newEventStartTime = "09:00";
601
+ newEventEndTime = "10:00";
602
+ newEventColor = "#2563eb";
603
+ newEventDescription = "";
604
+ modalOpen = true;
605
+ }
606
+
607
+ function handleSaveEvent() {
608
+ if (!newEventTitle.trim()) return;
609
+
610
+ const [startH, startM] = newEventStartTime.split(":").map(Number);
611
+ const [endH, endM] = newEventEndTime.split(":").map(Number);
612
+
613
+ const startDate = new Date(newEventDate);
614
+ startDate.setHours(startH || 0, startM || 0, 0, 0);
615
+
616
+ const endDate = new Date(newEventDate);
617
+ endDate.setHours(endH || 0, endM || 0, 0, 0);
618
+
619
+ const newEv: CalendarEvent = {
620
+ id: `ev-${Date.now()}`,
621
+ title: newEventTitle.trim(),
622
+ start: startDate,
623
+ end: endDate,
624
+ color: newEventColor,
625
+ description: newEventDescription.trim() || undefined,
626
+ };
627
+
628
+ events = [...events, newEv];
629
+ onAddEvent?.(newEv);
630
+ modalOpen = false;
631
+ }
632
+ </script>
633
+
634
+ <div
635
+ class={cn(
636
+ "relative flex flex-col rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] shadow-xs overflow-hidden",
637
+ className,
638
+ )}
639
+ >
640
+ <!-- Toolbar Header -->
641
+ <div
642
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-[var(--ui-border)] p-4 sm:px-6"
643
+ >
644
+ <div class="flex flex-col sm:flex-row sm:items-center gap-2">
645
+ <h2 class="text-base font-bold text-[var(--ui-foreground)] capitalize">
646
+ {titleHeader}
647
+ </h2>
648
+ <div class="flex items-center gap-1">
649
+ <Button variant="outline" size="sm" class="size-8 p-0" onclick={goPrev}>
650
+ <ChevronLeft class="size-4" />
651
+ </Button>
652
+ <Button
653
+ variant="outline"
654
+ size="sm"
655
+ class="px-2.5 h-8 text-xs font-semibold"
656
+ onclick={goToday}
657
+ >
658
+ Today
659
+ </Button>
660
+ <Button variant="outline" size="sm" class="size-8 p-0" onclick={goNext}>
661
+ <ChevronRight class="size-4" />
662
+ </Button>
663
+ </div>
664
+ <span
665
+ class="hidden sm:inline-flex items-center rounded-md bg-[var(--ui-secondary)]/50 px-2 py-0.5 text-[10px] font-medium text-[var(--ui-muted-foreground)]"
666
+ >
667
+ {activeTimeZone}
668
+ </span>
669
+ </div>
670
+
671
+ <div class="flex items-center gap-2">
672
+ <!-- View selector tabs -->
673
+ <div
674
+ class="inline-flex rounded-lg border border-[var(--ui-border)] bg-[var(--ui-secondary)]/30 p-1"
675
+ >
676
+ <button
677
+ type="button"
678
+ onclick={() => (view = "month")}
679
+ class={cn(
680
+ "rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer",
681
+ view === "month"
682
+ ? "bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs"
683
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]",
684
+ )}
685
+ >
686
+ <CalendarDays class="size-4 sm:hidden" />
687
+ <span class="hidden sm:inline">Month</span>
688
+ </button>
689
+ <button
690
+ type="button"
691
+ onclick={() => (view = "week")}
692
+ class={cn(
693
+ "rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer",
694
+ view === "week"
695
+ ? "bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs"
696
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]",
697
+ )}
698
+ >
699
+ <CalendarIcon class="size-4 sm:hidden" />
700
+ <span class="hidden sm:inline">Week</span>
701
+ </button>
702
+ <button
703
+ type="button"
704
+ onclick={() => (view = "day")}
705
+ class={cn(
706
+ "rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer",
707
+ view === "day"
708
+ ? "bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs"
709
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]",
710
+ )}
711
+ >
712
+ <Clock class="size-4 sm:hidden" />
713
+ <span class="hidden sm:inline">Day</span>
714
+ </button>
715
+ <button
716
+ type="button"
717
+ onclick={() => (view = "resourceTimeline")}
718
+ class={cn(
719
+ "rounded-md px-2 sm:px-3 py-1 text-xs font-semibold transition-colors cursor-pointer",
720
+ view === "resourceTimeline"
721
+ ? "bg-[var(--ui-card)] text-[var(--ui-foreground)] shadow-xs"
722
+ : "text-[var(--ui-muted-foreground)] hover:text-[var(--ui-foreground)]",
723
+ )}
724
+ >
725
+ <span class="hidden sm:inline">Resources</span>
726
+ <span class="sm:hidden">Rows</span>
727
+ </button>
728
+ </div>
729
+
730
+ {#if enableEventModal}
731
+ <Button
732
+ size="sm"
733
+ class="gap-1.5 h-8 text-xs"
734
+ onclick={() => openCreateModal(currentDate)}
735
+ >
736
+ <Plus class="size-3.5" />
737
+ <span class="hidden sm:inline">Add Event</span>
738
+ </Button>
739
+ {/if}
740
+ </div>
741
+ </div>
742
+
743
+ <!-- Calendar Content -->
744
+ {#if view === "month"}
745
+ <!-- MONTH VIEW -->
746
+ <div
747
+ class="grid grid-cols-7 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2"
748
+ style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center;"
749
+ >
750
+ {#each dayNames as day}
751
+ <div class="capitalize">{day}</div>
752
+ {/each}
753
+ </div>
754
+
755
+ <div
756
+ class="grid grid-cols-7 divide-x divide-y divide-[var(--ui-border)]"
757
+ style="display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));"
758
+ >
759
+ {#each monthDays as { date, currentMonth }}
760
+ {@const dayEvents = getEventsForDay(date)}
761
+ {@const todayActive = isToday(date)}
762
+
763
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
764
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
765
+ <div
766
+ onclick={() => {
767
+ onDateClick?.(date);
768
+ openCreateModal(date);
769
+ }}
770
+ ondragover={(e) => e.preventDefault()}
771
+ ondrop={(e) => {
772
+ e.preventDefault();
773
+ e.stopPropagation();
774
+ handleEventDrop(date);
775
+ }}
776
+ class={cn(
777
+ "min-h-[80px] md:min-h-[110px] p-1 md:p-1.5 flex flex-col gap-1 transition-colors hover:bg-[var(--ui-secondary)]/25 cursor-pointer select-none",
778
+ !currentMonth && "bg-[var(--ui-muted)]/15 opacity-50",
779
+ )}
780
+ >
781
+ <div class="flex items-center justify-between mb-0.5 px-1">
782
+ <span
783
+ class={cn(
784
+ "text-xs font-semibold size-6 flex items-center justify-center rounded-full",
785
+ todayActive
786
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]"
787
+ : "text-[var(--ui-foreground)]",
788
+ )}
789
+ >
790
+ {date.getDate()}
791
+ </span>
792
+ {#if dayEvents.length > 0}
793
+ <span
794
+ class="text-[10px] text-[var(--ui-muted-foreground)] font-medium"
795
+ >
796
+ {dayEvents.length} ev
797
+ </span>
798
+ {/if}
799
+ </div>
800
+
801
+ <!-- Event pills with spanning indicator -->
802
+ <div class="flex flex-col gap-1 overflow-hidden">
803
+ {#each dayEvents.slice(0, isMobile ? 2 : 3) as ev (ev.id)}
804
+ {@const isSpanning = isMultiDayOrAllDay(ev)}
805
+ {@const isStartDay = isSameDay(new Date(ev.start), date)}
806
+ {@const isEndDay = isSameDay(new Date(ev.end), date)}
807
+
808
+ <button
809
+ type="button"
810
+ draggable="true"
811
+ ondragstart={(e) => {
812
+ e.stopPropagation();
813
+ draggedEventId = ev.id;
814
+ e.dataTransfer?.setData("text/plain", ev.id);
815
+ }}
816
+ onclick={(e) => {
817
+ e.stopPropagation();
818
+ onEventClick?.(ev);
819
+ }}
820
+ class={cn(
821
+ "truncate px-1.5 py-0.5 text-left text-[10px] md:text-[11px] font-medium transition-opacity hover:opacity-85 text-white shadow-2xs cursor-grab active:cursor-grabbing",
822
+ isSpanning ? "rounded-none" : "rounded",
823
+ isSpanning && isStartDay && "rounded-l-md",
824
+ isSpanning && isEndDay && "rounded-r-md",
825
+ draggedEventId === ev.id
826
+ ? "opacity-40 ring-2 ring-white"
827
+ : "",
828
+ )}
829
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
830
+ >
831
+ {#if isSpanning}
832
+ <span class="font-semibold">{ev.title}</span>
833
+ {:else}
834
+ <span class="opacity-80 font-normal"
835
+ >{formatTime(ev.start)}</span
836
+ >
837
+ {ev.title}
838
+ {/if}
839
+ </button>
840
+ {/each}
841
+
842
+ {#if dayEvents.length > (isMobile ? 2 : 3)}
843
+ <span
844
+ class="text-[10px] font-semibold text-[var(--ui-muted-foreground)] px-1"
845
+ >
846
+ +{dayEvents.length - (isMobile ? 2 : 3)} more
847
+ </span>
848
+ {/if}
849
+ </div>
850
+ </div>
851
+ {/each}
852
+ </div>
853
+ {:else if view === "week"}
854
+ <!-- WEEK VIEW -->
855
+ <div
856
+ class="grid grid-cols-4 md:grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-center text-xs font-semibold text-[var(--ui-muted-foreground)] py-2"
857
+ >
858
+ <div class="col-span-1">Time</div>
859
+ {#each isMobile ? mobileWeekDays : weekDays as d}
860
+ <div class="col-span-1 flex flex-col items-center">
861
+ <span class="capitalize"
862
+ >{format(d, "EEE", { locale: activeDateFnsLocale })}</span
863
+ >
864
+ <span
865
+ class={cn(
866
+ "size-6 flex items-center justify-center rounded-full text-xs mt-0.5",
867
+ isToday(d)
868
+ ? "bg-[var(--ui-primary)] text-[var(--ui-primary-foreground)]"
869
+ : "",
870
+ )}
871
+ >
872
+ {d.getDate()}
873
+ </span>
874
+ </div>
875
+ {/each}
876
+ </div>
877
+
878
+ <!-- All-day header slot (FullCalendar 1:1) -->
879
+ {@const allDayEvents = getAllDayEventsForWeek(
880
+ isMobile ? mobileWeekDays : weekDays,
881
+ )}
882
+ {#if allDayEvents.length > 0}
883
+ <div
884
+ class="grid grid-cols-4 md:grid-cols-8 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/10 text-xs divide-x divide-[var(--ui-border)] py-1.5"
885
+ >
886
+ <div
887
+ class="col-span-1 text-right pr-2 text-[10px] uppercase tracking-wider font-semibold text-[var(--ui-muted-foreground)] flex items-center justify-end"
888
+ >
889
+ all-day
890
+ </div>
891
+ <div class="col-span-3 md:col-span-7 px-2 flex flex-col gap-1">
892
+ {#each allDayEvents as ev (ev.id)}
893
+ <button
894
+ type="button"
895
+ onclick={(e) => {
896
+ e.stopPropagation();
897
+ onEventClick?.(ev);
898
+ }}
899
+ class="w-full truncate rounded px-2 py-0.5 text-left text-[11px] font-semibold text-white shadow-2xs hover:opacity-90"
900
+ style="background-color: {ev.color || 'var(--ui-primary)'};"
901
+ >
902
+ {ev.title}
903
+ {#if ev.description}• {ev.description}{/if}
904
+ </button>
905
+ {/each}
906
+ </div>
907
+ </div>
908
+ {/if}
909
+
910
+ <div
911
+ class="grid grid-cols-4 md:grid-cols-8 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto"
912
+ >
913
+ <!-- Hours Column -->
914
+ <div
915
+ class="col-span-1 divide-y divide-[var(--ui-border)]/50 text-right pr-2 text-[11px] font-medium text-[var(--ui-muted-foreground)]"
916
+ >
917
+ {#each hours as hour}
918
+ <div class="h-14 pt-1">{String(hour).padStart(2, "0")}:00</div>
919
+ {/each}
920
+ </div>
921
+
922
+ <!-- Day Grid Columns -->
923
+ {#each isMobile ? mobileWeekDays : weekDays as d}
924
+ {@const dayEvents = getTimedEventsForDay(d)}
925
+ {@const layoutEvents = computeOverlappingLayout(dayEvents)}
926
+ {@const isCurrentDay = isToday(d)}
927
+ {@const nowTime = getTimeInZone(new Date())}
928
+ {@const nowMinutes = nowTime.hours * 60 + nowTime.minutes}
929
+ {@const currentTimeTop = (nowMinutes / 60) * 56}
930
+
931
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
932
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
933
+ <div
934
+ id="week-day-col"
935
+ onclick={() => openCreateModal(d)}
936
+ class="col-span-1 divide-y divide-[var(--ui-border)]/50 relative hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
937
+ >
938
+ {#each hours as hour}
939
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
940
+ <div
941
+ class="h-14 transition-colors hover:bg-[var(--ui-primary)]/10 pointer-events-auto"
942
+ onmousedown={(e) =>
943
+ handleDragCreateMouseDown(
944
+ e,
945
+ d,
946
+ hour,
947
+ e.currentTarget.closest('[id="week-day-col"]')!,
948
+ )}
949
+ ondragover={(e) => e.preventDefault()}
950
+ ondrop={(e) => {
951
+ e.preventDefault();
952
+ e.stopPropagation();
953
+ handleEventDrop(d, hour);
954
+ }}
955
+ ></div>
956
+ {/each}
957
+
958
+ <!-- Google Calendar style Current Time Red Line -->
959
+ {#if isCurrentDay}
960
+ <div
961
+ class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
962
+ style="top: {currentTimeTop}px;"
963
+ >
964
+ <div class="size-2 rounded-full bg-red-500 -ml-1"></div>
965
+ <div class="h-0.5 w-full bg-red-500"></div>
966
+ </div>
967
+ {/if}
968
+
969
+ <!-- Render Event Boxes with Collision Overlap Calculation & Resizing -->
970
+ {#each layoutEvents as ev (ev.id)}
971
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
972
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
973
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
974
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
975
+ {@const durationMinutes = Math.max(
976
+ 15,
977
+ endMinutes > startMinutes ? endMinutes - startMinutes : 60,
978
+ )}
979
+ {@const topPos = (startMinutes / 60) * 56}
980
+ {@const heightPos = Math.max(26, (durationMinutes / 60) * 56)}
981
+ {@const widthPercent = 100 / ev.colTotal}
982
+ {@const leftPercent = ev.colIndex * widthPercent}
983
+
984
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
985
+ <div
986
+ role="button"
987
+ tabindex="0"
988
+ draggable="true"
989
+ ondragstart={(e) => {
990
+ draggedEventId = ev.id;
991
+ e.dataTransfer?.setData("text/plain", ev.id);
992
+ }}
993
+ onclick={(e) => {
994
+ e.stopPropagation();
995
+ onEventClick?.(ev);
996
+ }}
997
+ onkeydown={(e) => {
998
+ if (e.key === "Enter" || e.key === " ") {
999
+ e.preventDefault();
1000
+ onEventClick?.(ev);
1001
+ }
1002
+ }}
1003
+ class="group/event absolute rounded p-1 text-left text-[11px] text-white shadow-xs transition-opacity hover:opacity-95 overflow-hidden z-10 cursor-grab active:cursor-grabbing {draggedEventId ===
1004
+ ev.id
1005
+ ? 'opacity-40 ring-2 ring-white'
1006
+ : ''}"
1007
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 2px); width: calc({widthPercent}% - 4px); background-color: {ev.color ||
1008
+ 'var(--ui-primary)'};"
1009
+ >
1010
+ <div class="font-bold truncate leading-tight">{ev.title}</div>
1011
+ <div class="text-[9px] opacity-85 leading-tight">
1012
+ {formatTime(ev.start)} - {formatTime(ev.end)}
1013
+ </div>
1014
+
1015
+ <!-- Bottom edge resize handle (FullCalendar 1:1) -->
1016
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1017
+ <div
1018
+ class="absolute inset-x-0 bottom-0 h-2 cursor-s-resize opacity-0 group-hover/event:opacity-100 bg-white/20 hover:bg-white/40 transition-opacity z-20"
1019
+ onmousedown={(e) => handleResizeStart(e, ev)}
1020
+ ></div>
1021
+ </div>
1022
+ {/each}
1023
+ <!-- Drag-to-create preview -->
1024
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && dragCreateColumn && isSameDay(dragCreateColumn, d)}
1025
+ {@const startTotalMin =
1026
+ dragCreateStart.hour * 60 + dragCreateStart.minute}
1027
+ {@const endTotalMin =
1028
+ dragCreateEnd.hour * 60 + dragCreateEnd.minute}
1029
+ {@const previewTop = (startTotalMin / 60) * 56}
1030
+ {@const previewHeight = Math.max(
1031
+ 14,
1032
+ ((endTotalMin - startTotalMin) / 60) * 56,
1033
+ )}
1034
+ <div
1035
+ class="absolute inset-x-0.5 z-20 rounded bg-blue-500/30 border border-blue-400/50 pointer-events-none flex items-start justify-center pt-0.5"
1036
+ style="top: {previewTop}px; height: {previewHeight}px;"
1037
+ >
1038
+ <span class="text-[9px] font-semibold text-blue-700 select-none">
1039
+ {String(dragCreateStart.hour).padStart(2, "0")}:{String(
1040
+ dragCreateStart.minute,
1041
+ ).padStart(2, "0")} - {String(dragCreateEnd.hour).padStart(
1042
+ 2,
1043
+ "0",
1044
+ )}:{String(dragCreateEnd.minute).padStart(2, "0")}
1045
+ </span>
1046
+ </div>
1047
+ {/if}
1048
+ </div>
1049
+ {/each}
1050
+ </div>
1051
+ <!-- Mobile swipe hint -->
1052
+ {#if isMobile}
1053
+ <div
1054
+ class="px-4 py-2 text-center text-[11px] text-[var(--ui-muted-foreground)] border-t border-[var(--ui-border)]"
1055
+ >
1056
+ Swipe to see more days
1057
+ </div>
1058
+ {/if}
1059
+ {:else if view === "day"}
1060
+ <!-- DAY VIEW -->
1061
+ <div
1062
+ class="p-3 border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 flex items-center justify-between"
1063
+ >
1064
+ <div class="flex items-center gap-2">
1065
+ <Clock class="size-4 text-[var(--ui-muted-foreground)]" />
1066
+ <span class="text-sm font-semibold capitalize">{titleHeader}</span>
1067
+ </div>
1068
+ <span class="text-xs text-[var(--ui-muted-foreground)] font-medium">
1069
+ {getEventsForDay(currentDate).length} event(s)
1070
+ </span>
1071
+ </div>
1072
+
1073
+ <div
1074
+ class="grid grid-cols-12 divide-x divide-[var(--ui-border)] max-h-[600px] overflow-y-auto"
1075
+ >
1076
+ <div
1077
+ class="col-span-2 divide-y divide-[var(--ui-border)]/50 text-right pr-3 text-xs font-medium text-[var(--ui-muted-foreground)]"
1078
+ >
1079
+ {#each hours as hour}
1080
+ <div class="h-16 pt-2">{String(hour).padStart(2, "0")}:00</div>
1081
+ {/each}
1082
+ </div>
1083
+
1084
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1085
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1086
+ <div
1087
+ id="day-view-col"
1088
+ onclick={() => openCreateModal(currentDate)}
1089
+ class="col-span-10 divide-y divide-[var(--ui-border)]/50 relative p-1 hover:bg-[var(--ui-secondary)]/10 cursor-pointer select-none"
1090
+ >
1091
+ {#each hours as hour}
1092
+ <div
1093
+ class="h-16 pointer-events-auto"
1094
+ onmousedown={(e) =>
1095
+ handleDragCreateMouseDown(
1096
+ e,
1097
+ currentDate,
1098
+ hour,
1099
+ e.currentTarget.closest('[id="day-view-col"]')!,
1100
+ )}
1101
+ ></div>
1102
+ {/each}
1103
+
1104
+ <!-- Google Calendar style Current Time Red Line -->
1105
+ {#if isToday(currentDate)}
1106
+ {@const nowTime = getTimeInZone(new Date())}
1107
+ {@const currentTimeTop =
1108
+ ((nowTime.hours * 60 + nowTime.minutes) / 60) * 64}
1109
+ <div
1110
+ class="absolute inset-x-0 z-30 pointer-events-none flex items-center"
1111
+ style="top: {currentTimeTop}px;"
1112
+ >
1113
+ <div class="size-2.5 rounded-full bg-red-500 -ml-1"></div>
1114
+ <div class="h-0.5 w-full bg-red-500"></div>
1115
+ </div>
1116
+ {/if}
1117
+
1118
+ {#each computeOverlappingLayout(getTimedEventsForDay(currentDate)) as ev (ev.id)}
1119
+ {@const evStartTime = getTimeInZone(new Date(ev.start))}
1120
+ {@const evEndTime = getTimeInZone(new Date(ev.end))}
1121
+ {@const startMinutes = evStartTime.hours * 60 + evStartTime.minutes}
1122
+ {@const endMinutes = evEndTime.hours * 60 + evEndTime.minutes}
1123
+ {@const durationMinutes = Math.max(
1124
+ 15,
1125
+ endMinutes > startMinutes ? endMinutes - startMinutes : 60,
1126
+ )}
1127
+ {@const topPos = (startMinutes / 60) * 64}
1128
+ {@const heightPos = Math.max(32, (durationMinutes / 60) * 64)}
1129
+ {@const widthPercent = 100 / ev.colTotal}
1130
+ {@const leftPercent = ev.colIndex * widthPercent}
1131
+
1132
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1133
+ <div
1134
+ role="button"
1135
+ tabindex="0"
1136
+ draggable="true"
1137
+ ondragstart={(e) => {
1138
+ draggedEventId = ev.id;
1139
+ e.dataTransfer?.setData("text/plain", ev.id);
1140
+ }}
1141
+ onclick={(e) => {
1142
+ e.stopPropagation();
1143
+ onEventClick?.(ev);
1144
+ }}
1145
+ onkeydown={(e) => {
1146
+ if (e.key === "Enter" || e.key === " ") {
1147
+ e.preventDefault();
1148
+ onEventClick?.(ev);
1149
+ }
1150
+ }}
1151
+ class="group/event absolute rounded-lg p-2 text-left text-white shadow-sm transition-opacity hover:opacity-95 overflow-hidden z-10 cursor-grab active:cursor-grabbing {draggedEventId ===
1152
+ ev.id
1153
+ ? 'opacity-40 ring-2 ring-white'
1154
+ : ''}"
1155
+ style="top: {topPos}px; height: {heightPos}px; left: calc({leftPercent}% + 4px); width: calc({widthPercent}% - 8px); background-color: {ev.color ||
1156
+ 'var(--ui-primary)'};"
1157
+ >
1158
+ <div class="font-bold text-xs truncate leading-tight">
1159
+ {ev.title}
1160
+ </div>
1161
+ <div class="text-[10px] opacity-85 leading-tight">
1162
+ {formatTime(ev.start)} - {formatTime(ev.end)}
1163
+ {#if ev.description}• {ev.description}{/if}
1164
+ </div>
1165
+
1166
+ <!-- Bottom resize handle -->
1167
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1168
+ <div
1169
+ class="absolute inset-x-0 bottom-0 h-2.5 cursor-s-resize opacity-0 group-hover/event:opacity-100 bg-white/20 hover:bg-white/40 transition-opacity z-20"
1170
+ onmousedown={(e) => handleResizeStart(e, ev)}
1171
+ ></div>
1172
+ </div>
1173
+ {/each}
1174
+ <!-- Drag-to-create preview -->
1175
+ {#if isDragCreating && dragCreateStart && dragCreateEnd && isSameDay(dragCreateColumn!, currentDate)}
1176
+ {@const startTotalMin =
1177
+ dragCreateStart.hour * 60 + dragCreateStart.minute}
1178
+ {@const endTotalMin = dragCreateEnd.hour * 60 + dragCreateEnd.minute}
1179
+ {@const previewTop = (startTotalMin / 60) * 64}
1180
+ {@const previewHeight = Math.max(
1181
+ 16,
1182
+ ((endTotalMin - startTotalMin) / 60) * 64,
1183
+ )}
1184
+ <div
1185
+ class="absolute inset-x-1 z-20 rounded-lg bg-blue-500/30 border border-blue-400/50 pointer-events-none flex items-start justify-center pt-1"
1186
+ style="top: {previewTop}px; height: {previewHeight}px;"
1187
+ >
1188
+ <span class="text-[10px] font-semibold text-blue-700 select-none">
1189
+ {String(dragCreateStart.hour).padStart(2, "0")}:{String(
1190
+ dragCreateStart.minute,
1191
+ ).padStart(2, "0")} - {String(dragCreateEnd.hour).padStart(
1192
+ 2,
1193
+ "0",
1194
+ )}:{String(dragCreateEnd.minute).padStart(2, "0")}
1195
+ </span>
1196
+ </div>
1197
+ {/if}
1198
+ </div>
1199
+ </div>
1200
+ {:else if view === "resourceTimeline"}
1201
+ <div
1202
+ class="border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 px-4 py-2 text-xs text-[var(--ui-muted-foreground)]"
1203
+ >
1204
+ Resource timeline · {format(currentDate, "dd MMMM yyyy", {
1205
+ locale: activeDateFnsLocale,
1206
+ })}
1207
+ </div>
1208
+ {#if resources.length === 0}
1209
+ <div class="p-8 text-center text-sm text-[var(--ui-muted-foreground)]">
1210
+ Add resources to render warehouse, user, or room schedules.
1211
+ </div>
1212
+ {:else}
1213
+ <div class="overflow-x-auto">
1214
+ <div class="min-w-[760px]">
1215
+ <div
1216
+ class="grid grid-cols-[180px_1fr] border-b border-[var(--ui-border)] bg-[var(--ui-secondary)]/20 text-[10px] font-semibold text-[var(--ui-muted-foreground)]"
1217
+ >
1218
+ <div class="border-r border-[var(--ui-border)] px-3 py-2">
1219
+ Resource
1220
+ </div>
1221
+ <div
1222
+ class="relative grid"
1223
+ style="grid-template-columns: repeat({timelineHours.length}, minmax(0, 1fr));"
1224
+ >
1225
+ {#each timelineHours as hour}
1226
+ <div
1227
+ class="border-r border-[var(--ui-border)] px-1 py-2 text-center"
1228
+ >
1229
+ {String(hour).padStart(2, "0")}:00
1230
+ </div>
1231
+ {/each}
1232
+ </div>
1233
+ </div>
1234
+ {#each resources as resource (resource.id)}
1235
+ {@const resourceEvents = getResourceEvents(resource.id)}
1236
+ <div
1237
+ class="grid min-h-16 grid-cols-[180px_1fr] border-b border-[var(--ui-border)]"
1238
+ >
1239
+ <div class="border-r border-[var(--ui-border)] px-3 py-3">
1240
+ <div class="text-xs font-semibold text-[var(--ui-foreground)]">
1241
+ {resource.label}
1242
+ </div>
1243
+ {#if resource.description}<div
1244
+ class="mt-0.5 truncate text-[10px] text-[var(--ui-muted-foreground)]"
1245
+ >
1246
+ {resource.description}
1247
+ </div>{/if}
1248
+ </div>
1249
+ <div
1250
+ role="button"
1251
+ tabindex="0"
1252
+ class="relative min-h-16 bg-[var(--ui-card)]"
1253
+ onclick={() => onDateClick?.(currentDate)}
1254
+ onkeydown={(event) => {
1255
+ if (event.key === "Enter" || event.key === " ") {
1256
+ event.preventDefault();
1257
+ onDateClick?.(currentDate);
1258
+ }
1259
+ }}
1260
+ >
1261
+ <div
1262
+ class="pointer-events-none absolute inset-0 grid"
1263
+ style="grid-template-columns: repeat({timelineHours.length}, minmax(0, 1fr));"
1264
+ >
1265
+ {#each timelineHours as _hour}<div
1266
+ class="border-r border-[var(--ui-border)]/50"
1267
+ ></div>{/each}
1268
+ </div>
1269
+ {#each resourceEvents as ev (ev.id)}
1270
+ <button
1271
+ type="button"
1272
+ class="absolute top-2 h-12 min-w-4 truncate rounded px-2 text-left text-[10px] font-semibold text-white shadow-xs hover:opacity-90"
1273
+ style="left: {Math.max(
1274
+ 0,
1275
+ timelinePosition(new Date(ev.start)),
1276
+ )}%; width: {Math.max(
1277
+ 2,
1278
+ Math.min(100, timelineWidth(ev)),
1279
+ )}%; background-color: {ev.color ||
1280
+ resource.color ||
1281
+ 'var(--ui-primary)'};"
1282
+ onclick={(event) => {
1283
+ event.stopPropagation();
1284
+ onEventClick?.(ev);
1285
+ }}
1286
+ >
1287
+ <span>{ev.title}</span>
1288
+ <span class="ml-1 font-normal opacity-80"
1289
+ >{formatTime(ev.start)}</span
1290
+ >
1291
+ </button>
1292
+ {/each}
1293
+ </div>
1294
+ </div>
1295
+ {/each}
1296
+ </div>
1297
+ </div>
1298
+ {/if}
1299
+ {/if}
1300
+ <!-- Quick Event Creation Modal -->
1301
+ {#if modalOpen}
1302
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1303
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1304
+ <div
1305
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-xs p-4"
1306
+ >
1307
+ <div
1308
+ onclick={(e) => e.stopPropagation()}
1309
+ class="w-full max-w-md rounded-xl border border-[var(--ui-border)] bg-[var(--ui-card)] p-5 shadow-2xl animate-in fade-in-50 zoom-in-95"
1310
+ >
1311
+ <div class="flex items-center justify-between mb-4">
1312
+ <h3 class="text-sm font-bold text-[var(--ui-foreground)]">
1313
+ Add New Event
1314
+ </h3>
1315
+ <button
1316
+ type="button"
1317
+ onclick={() => (modalOpen = false)}
1318
+ class="rounded-full p-1 text-[var(--ui-muted-foreground)] hover:bg-[var(--ui-secondary)] hover:text-[var(--ui-foreground)]"
1319
+ >
1320
+ <X class="size-4" />
1321
+ </button>
1322
+ </div>
1323
+
1324
+ <div class="space-y-3.5 text-xs">
1325
+ <div>
1326
+ <label
1327
+ for="event-cal-title"
1328
+ class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]"
1329
+ >Title</label
1330
+ >
1331
+ <input
1332
+ id="event-cal-title"
1333
+ type="text"
1334
+ bind:value={newEventTitle}
1335
+ placeholder="Event title (e.g. Sprint Review)..."
1336
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-3 text-xs outline-none focus:border-[var(--ui-primary)]"
1337
+ />
1338
+ </div>
1339
+
1340
+ <div class="grid grid-cols-2 gap-3">
1341
+ <div>
1342
+ <label
1343
+ for="event-cal-start-time"
1344
+ class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]"
1345
+ >Start Time</label
1346
+ >
1347
+ <input
1348
+ id="event-cal-start-time"
1349
+ type="time"
1350
+ bind:value={newEventStartTime}
1351
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
1352
+ />
1353
+ </div>
1354
+ <div>
1355
+ <label
1356
+ for="event-cal-end-time"
1357
+ class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]"
1358
+ >End Time</label
1359
+ >
1360
+ <input
1361
+ id="event-cal-end-time"
1362
+ type="time"
1363
+ bind:value={newEventEndTime}
1364
+ class="h-9 w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] px-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
1365
+ />
1366
+ </div>
1367
+ </div>
1368
+
1369
+ <div>
1370
+ <span
1371
+ class="block mb-1.5 font-semibold text-[var(--ui-muted-foreground)]"
1372
+ >Tag Color</span
1373
+ >
1374
+ <div class="flex items-center gap-2">
1375
+ {#each presetColors as col}
1376
+ <button
1377
+ type="button"
1378
+ onclick={() => (newEventColor = col)}
1379
+ class={cn(
1380
+ "size-6 rounded-full transition-transform hover:scale-110 cursor-pointer",
1381
+ newEventColor === col &&
1382
+ "ring-2 ring-offset-2 ring-[var(--ui-primary)]",
1383
+ )}
1384
+ style="background-color: {col};"
1385
+ aria-label="Select color {col}"
1386
+ ></button>
1387
+ {/each}
1388
+ </div>
1389
+ </div>
1390
+
1391
+ <div>
1392
+ <label
1393
+ for="event-cal-desc"
1394
+ class="block mb-1 font-semibold text-[var(--ui-muted-foreground)]"
1395
+ >Description (optional)</label
1396
+ >
1397
+ <textarea
1398
+ id="event-cal-desc"
1399
+ bind:value={newEventDescription}
1400
+ class="w-full rounded-lg border border-[var(--ui-input)] bg-[var(--ui-background)] p-2.5 text-xs outline-none focus:border-[var(--ui-primary)]"
1401
+ ></textarea>
1402
+ </div>
1403
+ </div>
1404
+
1405
+ <div
1406
+ class="mt-5 flex items-center justify-end gap-2 border-t border-[var(--ui-border)] pt-3"
1407
+ >
1408
+ <Button
1409
+ variant="outline"
1410
+ size="sm"
1411
+ onclick={() => (modalOpen = false)}>Cancel</Button
1412
+ >
1413
+ <Button size="sm" onclick={handleSaveEvent}>Save Event</Button>
1414
+ </div>
1415
+ </div>
1416
+ </div>
1417
+ {/if}
1418
+ </div>