@object-ui/plugin-calendar 3.3.0 → 3.3.1

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.
@@ -1,821 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- "use client"
10
-
11
- import * as React from "react"
12
- import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon, PlusIcon } from "lucide-react"
13
- import {
14
- cn,
15
- Button,
16
- Select,
17
- SelectContent,
18
- SelectItem,
19
- SelectTrigger,
20
- SelectValue,
21
- Calendar,
22
- Popover,
23
- PopoverContent,
24
- PopoverTrigger
25
- } from "@object-ui/components"
26
- import { useObjectTranslation } from "@object-ui/i18n"
27
-
28
- const DEFAULT_EVENT_COLOR = "bg-blue-500 text-white"
29
- const STABLE_DEFAULT_DATE = new Date()
30
-
31
- // Default English translations for fallback when I18nProvider is not available
32
- const DEFAULT_TRANSLATIONS: Record<string, string> = {
33
- 'calendar.today': 'Today',
34
- 'calendar.month': 'Month',
35
- 'calendar.week': 'Week',
36
- 'calendar.day': 'Day',
37
- 'calendar.newEvent': 'New event',
38
- 'calendar.moreEvents': '+{{count}} more',
39
- }
40
-
41
- /**
42
- * Safe wrapper for useObjectTranslation that falls back to English defaults
43
- * when I18nProvider is not available (e.g., standalone usage outside console).
44
- */
45
- function useCalendarTranslation() {
46
- try {
47
- const result = useObjectTranslation()
48
- // Check if i18n is properly initialized by testing a known key
49
- const testValue = result.t('calendar.today')
50
- if (testValue === 'calendar.today') {
51
- // i18n returned the key itself — not initialized
52
- return {
53
- t: (key: string, options?: Record<string, unknown>) => {
54
- let value = DEFAULT_TRANSLATIONS[key] || key
55
- if (options) {
56
- for (const [k, v] of Object.entries(options)) {
57
- value = value.replace(`{{${k}}}`, String(v))
58
- }
59
- }
60
- return value
61
- },
62
- language: 'en',
63
- }
64
- }
65
- return { t: result.t, language: result.language }
66
- } catch {
67
- return {
68
- t: (key: string, options?: Record<string, unknown>) => {
69
- let value = DEFAULT_TRANSLATIONS[key] || key
70
- if (options) {
71
- for (const [k, v] of Object.entries(options)) {
72
- value = value.replace(`{{${k}}}`, String(v))
73
- }
74
- }
75
- return value
76
- },
77
- language: 'en',
78
- }
79
- }
80
- }
81
-
82
- export interface CalendarEvent {
83
- id: string | number
84
- title: string
85
- start: Date
86
- end?: Date
87
- allDay?: boolean
88
- color?: string
89
- data?: any
90
- }
91
-
92
- export interface CalendarViewProps {
93
- events?: CalendarEvent[]
94
- view?: "month" | "week" | "day"
95
- currentDate?: Date
96
- locale?: string
97
- onEventClick?: (event: CalendarEvent) => void
98
- onDateClick?: (date: Date) => void
99
- onViewChange?: (view: "month" | "week" | "day") => void
100
- onNavigate?: (date: Date) => void
101
- onAddClick?: () => void
102
- onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
103
- className?: string
104
- }
105
-
106
- function CalendarView({
107
- events = [],
108
- view = "month",
109
- currentDate = STABLE_DEFAULT_DATE,
110
- locale = "default",
111
- onEventClick,
112
- onDateClick,
113
- onViewChange,
114
- onNavigate,
115
- onAddClick,
116
- onEventDrop,
117
- className,
118
- }: CalendarViewProps) {
119
- const [selectedView, setSelectedView] = React.useState(view)
120
- const [selectedDate, setSelectedDate] = React.useState(currentDate)
121
- const { t, language } = useCalendarTranslation()
122
- const effectiveLocale = locale !== "default" ? locale : language
123
-
124
- // Sync state if props change
125
- React.useEffect(() => {
126
- setSelectedDate(currentDate)
127
- }, [currentDate])
128
-
129
- React.useEffect(() => {
130
- setSelectedView(view)
131
- }, [view])
132
-
133
- // Auto-switch to day view on mobile
134
- const onViewChangeRef = React.useRef(onViewChange)
135
- onViewChangeRef.current = onViewChange
136
-
137
- React.useEffect(() => {
138
- const mq = window.matchMedia("(max-width: 639px)")
139
- const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
140
- if (e.matches) {
141
- setSelectedView("day")
142
- onViewChangeRef.current?.("day")
143
- }
144
- }
145
- handleChange(mq)
146
- mq.addEventListener("change", handleChange)
147
- return () => mq.removeEventListener("change", handleChange)
148
- }, [])
149
-
150
- const handlePrevious = () => {
151
- const newDate = new Date(selectedDate)
152
- if (selectedView === "month") {
153
- newDate.setMonth(newDate.getMonth() - 1)
154
- } else if (selectedView === "week") {
155
- newDate.setDate(newDate.getDate() - 7)
156
- } else {
157
- newDate.setDate(newDate.getDate() - 1)
158
- }
159
- setSelectedDate(newDate)
160
- onNavigate?.(newDate)
161
- }
162
-
163
- const handleNext = () => {
164
- const newDate = new Date(selectedDate)
165
- if (selectedView === "month") {
166
- newDate.setMonth(newDate.getMonth() + 1)
167
- } else if (selectedView === "week") {
168
- newDate.setDate(newDate.getDate() + 7)
169
- } else {
170
- newDate.setDate(newDate.getDate() + 1)
171
- }
172
- setSelectedDate(newDate)
173
- onNavigate?.(newDate)
174
- }
175
-
176
- const handleToday = () => {
177
- const today = new Date()
178
- setSelectedDate(today)
179
- onNavigate?.(today)
180
- }
181
-
182
- const handleViewChange = (newView: "month" | "week" | "day") => {
183
- setSelectedView(newView)
184
- onViewChange?.(newView)
185
- }
186
-
187
- const getDateLabel = () => {
188
- if (selectedView === "month") {
189
- return selectedDate.toLocaleDateString(effectiveLocale, {
190
- month: "long",
191
- year: "numeric",
192
- })
193
- } else if (selectedView === "week") {
194
- const weekStart = getWeekStart(selectedDate)
195
- const weekEnd = new Date(weekStart)
196
- weekEnd.setDate(weekEnd.getDate() + 6)
197
- return `${weekStart.toLocaleDateString(effectiveLocale, {
198
- month: "short",
199
- day: "numeric",
200
- })} - ${weekEnd.toLocaleDateString(effectiveLocale, {
201
- month: "short",
202
- day: "numeric",
203
- year: "numeric",
204
- })}`
205
- } else {
206
- return selectedDate.toLocaleDateString(effectiveLocale, {
207
- weekday: "long",
208
- month: "long",
209
- day: "numeric",
210
- year: "numeric",
211
- })
212
- }
213
- }
214
-
215
- // Swipe navigation for mobile
216
- const touchStart = React.useRef<number>(0)
217
- const handleTouchStart = (e: React.TouchEvent) => {
218
- touchStart.current = e.touches[0].clientX
219
- }
220
- const handleTouchEnd = (e: React.TouchEvent) => {
221
- const diff = touchStart.current - e.changedTouches[0].clientX
222
- if (Math.abs(diff) > 50) {
223
- const newDate = new Date(selectedDate)
224
- if (selectedView === "day") newDate.setDate(newDate.getDate() + (diff > 0 ? 1 : -1))
225
- else if (selectedView === "week") newDate.setDate(newDate.getDate() + (diff > 0 ? 7 : -7))
226
- else newDate.setMonth(newDate.getMonth() + (diff > 0 ? 1 : -1))
227
- setSelectedDate(newDate)
228
- onNavigate?.(newDate)
229
- }
230
- }
231
-
232
- const handleDateSelect = (date: Date | undefined) => {
233
- if (date) {
234
- setSelectedDate(date)
235
- onNavigate?.(date)
236
- }
237
- }
238
-
239
- return (
240
- <div role="region" aria-label="Calendar" className={cn("flex flex-col h-full bg-background min-w-0 overflow-hidden", className)}>
241
- {/* Header */}
242
- <div className="flex flex-wrap items-center justify-between gap-2 p-2 sm:p-4 border-b min-w-0">
243
- <div className="flex items-center gap-4">
244
- <div className="flex items-center bg-muted/50 rounded-lg p-1 gap-1">
245
- <Button variant="ghost" size="sm" onClick={handleToday} className="h-8" aria-label="Go to today">
246
- {t('calendar.today')}
247
- </Button>
248
- <div className="h-4 w-px bg-border mx-1" />
249
- <Button
250
- variant="ghost"
251
- size="icon"
252
- aria-label="Previous period"
253
- onClick={handlePrevious}
254
- className="h-8 w-8"
255
- >
256
- <ChevronLeftIcon className="h-4 w-4" />
257
- </Button>
258
- <Button
259
- variant="ghost"
260
- size="icon"
261
- aria-label="Next period"
262
- onClick={handleNext}
263
- className="h-8 w-8"
264
- >
265
- <ChevronRightIcon className="h-4 w-4" />
266
- </Button>
267
- </div>
268
-
269
- <Popover>
270
- <PopoverTrigger asChild>
271
- <Button
272
- variant="ghost"
273
- aria-label={`Current date: ${getDateLabel()}`}
274
- className={cn(
275
- "text-base sm:text-xl font-semibold h-auto px-2 sm:px-3 py-1 hover:bg-muted/50 transition-colors",
276
- "flex items-center gap-2"
277
- )}
278
- >
279
- <CalendarIcon className="h-5 w-5 text-muted-foreground" />
280
- <span>{getDateLabel()}</span>
281
- </Button>
282
- </PopoverTrigger>
283
- <PopoverContent className="w-auto p-0" align="start">
284
- <Calendar
285
- mode="single"
286
- selected={selectedDate}
287
- onSelect={handleDateSelect}
288
- initialFocus
289
- fromYear={2000}
290
- toYear={2050}
291
- />
292
- </PopoverContent>
293
- </Popover>
294
- </div>
295
-
296
- <div className="flex items-center gap-2">
297
- <Select value={selectedView} onValueChange={handleViewChange}>
298
- <SelectTrigger className="w-32 bg-background">
299
- <SelectValue />
300
- </SelectTrigger>
301
- <SelectContent>
302
- <SelectItem value="day">{t('calendar.day')}</SelectItem>
303
- <SelectItem value="week">{t('calendar.week')}</SelectItem>
304
- <SelectItem value="month">{t('calendar.month')}</SelectItem>
305
- </SelectContent>
306
- </Select>
307
-
308
- {onAddClick && (
309
- <Button onClick={onAddClick} size="sm" className="gap-1">
310
- <PlusIcon className="h-4 w-4" />
311
- {t('calendar.newEvent')}
312
- </Button>
313
- )}
314
- </div>
315
- </div>
316
-
317
- {/* Calendar Grid */}
318
- <div className="flex-1 overflow-auto" onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
319
- {selectedView === "month" && (
320
- <MonthView
321
- date={selectedDate}
322
- events={events}
323
- locale={effectiveLocale}
324
- onEventClick={onEventClick}
325
- onDateClick={onDateClick}
326
- onEventDrop={onEventDrop}
327
- />
328
- )}
329
- {selectedView === "week" && (
330
- <WeekView
331
- date={selectedDate}
332
- events={events}
333
- locale={effectiveLocale}
334
- onEventClick={onEventClick}
335
- onDateClick={onDateClick}
336
- />
337
- )}
338
- {selectedView === "day" && (
339
- <DayView
340
- date={selectedDate}
341
- events={events}
342
- onEventClick={onEventClick}
343
- onDateClick={onDateClick}
344
- />
345
- )}
346
- </div>
347
- </div>
348
- )
349
- }
350
-
351
- function getWeekStart(date: Date): Date {
352
- const d = new Date(date)
353
- const day = d.getDay()
354
- const diff = d.getDate() - day
355
- d.setDate(diff)
356
- return d
357
- }
358
-
359
- function getMonthDays(date: Date): Date[] {
360
- const year = date.getFullYear()
361
- const month = date.getMonth()
362
- const firstDay = new Date(year, month, 1)
363
- const lastDay = new Date(year, month + 1, 0)
364
- const startDay = firstDay.getDay()
365
- const days: Date[] = []
366
-
367
- // Add previous month days
368
- for (let i = startDay - 1; i >= 0; i--) {
369
- const prevDate = new Date(firstDay.getTime())
370
- prevDate.setDate(prevDate.getDate() - (i + 1))
371
- days.push(prevDate)
372
- }
373
-
374
- // Add current month days
375
- for (let i = 1; i <= lastDay.getDate(); i++) {
376
- days.push(new Date(year, month, i))
377
- }
378
-
379
- // Add next month days
380
- const remainingDays = 42 - days.length
381
- for (let i = 1; i <= remainingDays; i++) {
382
- const nextDate = new Date(lastDay.getTime())
383
- nextDate.setDate(nextDate.getDate() + i)
384
- days.push(nextDate)
385
- }
386
-
387
- return days
388
- }
389
-
390
- function isSameDay(date1: Date, date2: Date): boolean {
391
- return (
392
- date1.getFullYear() === date2.getFullYear() &&
393
- date1.getMonth() === date2.getMonth() &&
394
- date1.getDate() === date2.getDate()
395
- )
396
- }
397
-
398
- function getEventsForDate(date: Date, events: CalendarEvent[]): CalendarEvent[] {
399
- return events.filter((event) => {
400
- const eventStart = new Date(event.start)
401
- const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
402
-
403
- // Create new date objects for comparison to avoid mutation
404
- const dateStart = new Date(date)
405
- dateStart.setHours(0, 0, 0, 0)
406
- const dateEnd = new Date(date)
407
- dateEnd.setHours(23, 59, 59, 999)
408
-
409
- const eventStartTime = new Date(eventStart)
410
- eventStartTime.setHours(0, 0, 0, 0)
411
- const eventEndTime = new Date(eventEnd)
412
- eventEndTime.setHours(23, 59, 59, 999)
413
-
414
- return dateStart <= eventEndTime && dateEnd >= eventStartTime
415
- })
416
- }
417
-
418
- interface MonthViewProps {
419
- date: Date
420
- events: CalendarEvent[]
421
- locale?: string
422
- onEventClick?: (event: CalendarEvent) => void
423
- onDateClick?: (date: Date) => void
424
- onEventDrop?: (event: CalendarEvent, newStart: Date, newEnd?: Date) => void
425
- }
426
-
427
- function MonthView({ date, events, locale = "default", onEventClick, onDateClick, onEventDrop }: MonthViewProps) {
428
- const days = React.useMemo(() => getMonthDays(date), [date.getFullYear(), date.getMonth()])
429
- const today = React.useMemo(() => new Date(), [])
430
- const { t } = useCalendarTranslation()
431
- const weekDays = React.useMemo(() => {
432
- const refSunday = new Date(2024, 0, 7)
433
- return Array.from({ length: 7 }, (_, i) => {
434
- const d = new Date(refSunday)
435
- d.setDate(d.getDate() + i)
436
- return d.toLocaleDateString(locale, { weekday: "short" })
437
- })
438
- }, [locale])
439
- const [draggedEventId, setDraggedEventId] = React.useState<string | number | null>(null)
440
- const [dropTargetIndex, setDropTargetIndex] = React.useState<number | null>(null)
441
-
442
- // Pre-build event index by date key for O(1) lookup per cell instead of O(N)
443
- const eventsByDate = React.useMemo(() => {
444
- const map = new Map<string, CalendarEvent[]>()
445
- for (const event of events) {
446
- const eventStart = new Date(event.start)
447
- const eventEnd = event.end ? new Date(event.end) : new Date(eventStart)
448
- eventStart.setHours(0, 0, 0, 0)
449
- eventEnd.setHours(0, 0, 0, 0)
450
- const cursor = new Date(eventStart)
451
- while (cursor <= eventEnd) {
452
- const key = `${cursor.getFullYear()}-${cursor.getMonth()}-${cursor.getDate()}`
453
- const arr = map.get(key)
454
- if (arr) {
455
- arr.push(event)
456
- } else {
457
- map.set(key, [event])
458
- }
459
- cursor.setDate(cursor.getDate() + 1)
460
- }
461
- }
462
- return map
463
- }, [events])
464
-
465
- const handleDragStart = (e: React.DragEvent, event: CalendarEvent) => {
466
- setDraggedEventId(event.id)
467
- e.dataTransfer.effectAllowed = "move"
468
- e.dataTransfer.setData("text/plain", String(event.id))
469
- }
470
-
471
- const handleDragEnd = () => {
472
- setDraggedEventId(null)
473
- setDropTargetIndex(null)
474
- }
475
-
476
- const handleDragOver = (e: React.DragEvent, index: number) => {
477
- e.preventDefault()
478
- e.dataTransfer.dropEffect = "move"
479
- setDropTargetIndex(index)
480
- }
481
-
482
- const handleDragLeave = (e: React.DragEvent) => {
483
- // Only clear when actually leaving the cell, not when moving over child elements
484
- if (!e.currentTarget.contains(e.relatedTarget as Node)) {
485
- setDropTargetIndex(null)
486
- }
487
- }
488
-
489
- const handleDrop = (e: React.DragEvent, targetDay: Date) => {
490
- e.preventDefault()
491
- setDropTargetIndex(null)
492
- setDraggedEventId(null)
493
-
494
- if (!onEventDrop) return
495
-
496
- const eventId = e.dataTransfer.getData("text/plain")
497
- const draggedEvent = events.find((ev) => String(ev.id) === eventId)
498
- if (!draggedEvent) return
499
-
500
- const oldStart = new Date(draggedEvent.start)
501
- const oldStartDay = new Date(oldStart)
502
- oldStartDay.setHours(0, 0, 0, 0)
503
-
504
- const newTargetDay = new Date(targetDay)
505
- newTargetDay.setHours(0, 0, 0, 0)
506
-
507
- const deltaMs = newTargetDay.getTime() - oldStartDay.getTime()
508
- if (deltaMs === 0) return
509
-
510
- const newStart = new Date(oldStart.getTime() + deltaMs)
511
-
512
- let newEnd: Date | undefined
513
- if (draggedEvent.end) {
514
- newEnd = new Date(new Date(draggedEvent.end).getTime() + deltaMs)
515
- }
516
-
517
- onEventDrop(draggedEvent, newStart, newEnd)
518
- }
519
-
520
- return (
521
- <div className="flex flex-col h-full">
522
- {/* Week day headers */}
523
- <div role="row" className="grid grid-cols-7 border-b">
524
- {weekDays.map((day) => (
525
- <div
526
- key={day}
527
- role="columnheader"
528
- className="p-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
529
- >
530
- {day}
531
- </div>
532
- ))}
533
- </div>
534
-
535
- {/* Calendar days */}
536
- <div role="grid" aria-label="Calendar grid" className="grid grid-cols-7 flex-1 auto-rows-fr">
537
- {days.map((day, index) => {
538
- const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`
539
- const dayEvents = eventsByDate.get(key) || []
540
- const isCurrentMonth = day.getMonth() === date.getMonth()
541
- const isToday = isSameDay(day, today)
542
-
543
- return (
544
- <div
545
- key={index}
546
- role="gridcell"
547
- aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
548
- className={cn(
549
- "border-b border-r last:border-r-0 p-2 min-h-[100px] cursor-pointer hover:bg-accent/50",
550
- !isCurrentMonth && "bg-muted/50 text-muted-foreground opacity-50",
551
- dropTargetIndex === index && "ring-2 ring-primary"
552
- )}
553
- onClick={() => onDateClick?.(day)}
554
- onDragOver={(e) => handleDragOver(e, index)}
555
- onDragLeave={handleDragLeave}
556
- onDrop={(e) => handleDrop(e, day)}
557
- >
558
- <div
559
- className={cn(
560
- "text-sm font-medium mb-2",
561
- isToday &&
562
- "inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-6 w-6"
563
- )}
564
- {...(isToday ? { "aria-current": "date" as const } : {})}
565
- >
566
- {day.getDate()}
567
- </div>
568
- <div className="space-y-1">
569
- {dayEvents.slice(0, 3).map((event) => (
570
- <div
571
- key={event.id}
572
- role="button"
573
- title={event.title}
574
- aria-label={event.title}
575
- draggable={!!onEventDrop}
576
- onDragStart={(e) => handleDragStart(e, event)}
577
- onDragEnd={handleDragEnd}
578
- className={cn(
579
- "text-xs px-2 py-1 rounded truncate cursor-pointer hover:opacity-80",
580
- event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR),
581
- draggedEventId === event.id && "opacity-50"
582
- )}
583
- style={
584
- event.color && event.color.startsWith("#")
585
- ? { backgroundColor: event.color }
586
- : undefined
587
- }
588
- onClick={(e) => {
589
- e.stopPropagation()
590
- onEventClick?.(event)
591
- }}
592
- >
593
- {event.title}
594
- </div>
595
- ))}
596
- {dayEvents.length > 3 && (
597
- <div className="text-xs text-muted-foreground px-2">
598
- {t('calendar.moreEvents', { count: dayEvents.length - 3 })}
599
- </div>
600
- )}
601
- </div>
602
- </div>
603
- )
604
- })}
605
- </div>
606
- </div>
607
- )
608
- }
609
-
610
- interface WeekViewProps {
611
- date: Date
612
- events: CalendarEvent[]
613
- locale?: string
614
- onEventClick?: (event: CalendarEvent) => void
615
- onDateClick?: (date: Date) => void
616
- }
617
-
618
- function WeekView({ date, events, locale = "default", onEventClick, onDateClick }: WeekViewProps) {
619
- const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
620
-
621
- const handleSlotTouchStart = (day: Date) => {
622
- if (!onDateClick) return
623
- longPressTimer.current = setTimeout(() => {
624
- onDateClick(day)
625
- }, 500)
626
- }
627
-
628
- const handleSlotTouchEnd = () => {
629
- if (longPressTimer.current) {
630
- clearTimeout(longPressTimer.current)
631
- longPressTimer.current = null
632
- }
633
- }
634
-
635
- const weekStart = getWeekStart(date)
636
- const weekDays = Array.from({ length: 7 }, (_, i) => {
637
- const day = new Date(weekStart)
638
- day.setDate(day.getDate() + i)
639
- return day
640
- })
641
- const today = new Date()
642
-
643
- return (
644
- <div className="flex flex-col h-full">
645
- {/* Week day headers */}
646
- <div className="grid grid-cols-7 border-b">
647
- {weekDays.map((day) => {
648
- const isToday = isSameDay(day, today)
649
- return (
650
- <div
651
- key={day.toISOString()}
652
- className="p-3 text-center border-r last:border-r-0"
653
- >
654
- <div className="text-sm font-medium text-muted-foreground">
655
- {day.toLocaleDateString(locale, { weekday: "short" })}
656
- </div>
657
- <div
658
- className={cn(
659
- "text-lg font-semibold mt-1",
660
- isToday &&
661
- "inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground h-8 w-8"
662
- )}
663
- >
664
- {day.getDate()}
665
- </div>
666
- </div>
667
- )
668
- })}
669
- </div>
670
-
671
- {/* Week events */}
672
- <div role="grid" className="grid grid-cols-7 flex-1">
673
- {weekDays.map((day) => {
674
- const dayEvents = getEventsForDate(day, events)
675
- return (
676
- <div
677
- key={day.toISOString()}
678
- role="gridcell"
679
- aria-label={`${day.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${dayEvents.length > 0 ? `, ${dayEvents.length} event${dayEvents.length > 1 ? "s" : ""}` : ""}`}
680
- className="border-r last:border-r-0 p-2 min-h-[400px] cursor-pointer hover:bg-accent/50"
681
- onClick={() => onDateClick?.(day)}
682
- onTouchStart={() => handleSlotTouchStart(day)}
683
- onTouchEnd={handleSlotTouchEnd}
684
- >
685
- <div className="space-y-2">
686
- {dayEvents.map((event) => (
687
- <div
688
- key={event.id}
689
- role="button"
690
- title={event.title}
691
- aria-label={event.title}
692
- className={cn(
693
- "text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
694
- event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
695
- )}
696
- style={
697
- event.color && event.color.startsWith("#")
698
- ? { backgroundColor: event.color }
699
- : undefined
700
- }
701
- onClick={(e) => {
702
- e.stopPropagation()
703
- onEventClick?.(event)
704
- }}
705
- >
706
- <div className="font-medium truncate">{event.title}</div>
707
- {!event.allDay && (
708
- <div className="text-xs opacity-90 mt-1">
709
- {event.start.toLocaleTimeString("default", {
710
- hour: "numeric",
711
- minute: "2-digit",
712
- })}
713
- </div>
714
- )}
715
- </div>
716
- ))}
717
- </div>
718
- </div>
719
- )
720
- })}
721
- </div>
722
- </div>
723
- )
724
- }
725
-
726
- interface DayViewProps {
727
- date: Date
728
- events: CalendarEvent[]
729
- onEventClick?: (event: CalendarEvent) => void
730
- onDateClick?: (date: Date) => void
731
- }
732
-
733
- function DayView({ date, events, onEventClick, onDateClick }: DayViewProps) {
734
- const dayEvents = getEventsForDate(date, events)
735
- const hours = Array.from({ length: 24 }, (_, i) => i)
736
- const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
737
-
738
- const handleSlotTouchStart = (hour: number) => {
739
- if (!onDateClick) return
740
- longPressTimer.current = setTimeout(() => {
741
- const clickDate = new Date(date)
742
- clickDate.setHours(hour, 0, 0, 0)
743
- onDateClick(clickDate)
744
- }, 500)
745
- }
746
-
747
- const handleSlotTouchEnd = () => {
748
- if (longPressTimer.current) {
749
- clearTimeout(longPressTimer.current)
750
- longPressTimer.current = null
751
- }
752
- }
753
-
754
- return (
755
- <div className="flex flex-col h-full">
756
- <div role="list" className="flex-1 overflow-auto">
757
- {hours.map((hour) => {
758
- const hourEvents = dayEvents.filter((event) => {
759
- if (event.allDay) return hour === 0
760
- const eventHour = event.start.getHours()
761
- return eventHour === hour
762
- })
763
-
764
- return (
765
- <div key={hour} role="listitem" className="flex border-b min-h-[60px]">
766
- <div className="w-20 p-2 text-sm text-muted-foreground border-r">
767
- {hour === 0
768
- ? "12 AM"
769
- : hour < 12
770
- ? `${hour} AM`
771
- : hour === 12
772
- ? "12 PM"
773
- : `${hour - 12} PM`}
774
- </div>
775
- <div
776
- className="flex-1 p-2 space-y-2"
777
- onTouchStart={() => handleSlotTouchStart(hour)}
778
- onTouchEnd={handleSlotTouchEnd}
779
- >
780
- {hourEvents.map((event) => (
781
- <div
782
- key={event.id}
783
- title={event.title}
784
- aria-label={event.title}
785
- className={cn(
786
- "px-2 sm:px-3 py-1.5 sm:py-2 rounded cursor-pointer hover:opacity-80",
787
- event.color?.startsWith("#") ? "text-white" : (event.color || DEFAULT_EVENT_COLOR)
788
- )}
789
- style={
790
- event.color && event.color.startsWith("#")
791
- ? { backgroundColor: event.color }
792
- : undefined
793
- }
794
- onClick={() => onEventClick?.(event)}
795
- >
796
- <div className="font-medium truncate">{event.title}</div>
797
- {!event.allDay && (
798
- <div className="text-xs opacity-90 mt-1">
799
- {event.start.toLocaleTimeString("default", {
800
- hour: "numeric",
801
- minute: "2-digit",
802
- })}
803
- {event.end &&
804
- ` - ${event.end.toLocaleTimeString("default", {
805
- hour: "numeric",
806
- minute: "2-digit",
807
- })}`}
808
- </div>
809
- )}
810
- </div>
811
- ))}
812
- </div>
813
- </div>
814
- )
815
- })}
816
- </div>
817
- </div>
818
- )
819
- }
820
-
821
- export { CalendarView }