@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.
- package/dist/Calendar.svelte +142 -30
- package/dist/DatePicker.svelte +27 -13
- package/dist/DatePicker.svelte.d.ts +1 -1
- package/dist/DateRangePicker.svelte +392 -0
- package/dist/DateRangePicker.svelte.d.ts +28 -0
- package/dist/EventCalendar.svelte +1418 -0
- package/dist/EventCalendar.svelte.d.ts +48 -0
- package/dist/helpers.test.d.ts +1 -0
- package/dist/helpers.test.js +129 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -2
- package/dist/recurrence.d.ts +31 -0
- package/dist/recurrence.js +57 -0
- package/package.json +11 -6
|
@@ -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>
|