Skip to content

Commit d4e34dd

Browse files
feat: calendar
1 parent 475b182 commit d4e34dd

4 files changed

Lines changed: 486 additions & 227 deletions

File tree

examples/react/basic/src/index.tsx

Lines changed: 61 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { useCalendar } from '@tanstack/react-time'
1+
import {
2+
calculateGhostPreviewStyle,
3+
calculateSegmentResizePreview,
4+
formatEventTimeRange,
5+
getSegmentInfo,
6+
useCalendar,
7+
} from '@tanstack/react-time'
28
import ReactDOM from 'react-dom/client'
39
import { useState } from 'react'
410
import type { Day, Event, Resource } from '@tanstack/time'
@@ -229,8 +235,6 @@ function EventModal({
229235
)
230236
}
231237

232-
const MINUTES_IN_DAY = 24 * 60
233-
234238
interface ResizeHandleProps {
235239
edge: 'top' | 'bottom'
236240
onMouseDown: (e: React.MouseEvent) => void
@@ -274,7 +278,7 @@ function ScheduleView({
274278
onEventClick: (event: Event<Resource>) => void
275279
}) {
276280
const timeSlots = calendar.getTimeSlots()
277-
const { resizeState, getResizeHandleProps } = calendar
281+
const { resizeState, getResizeHandleProps, getDayColumnProps } = calendar
278282

279283
return (
280284
<div className="flex border border-gray-200 rounded-lg overflow-hidden bg-white">
@@ -306,7 +310,7 @@ function ScheduleView({
306310
<div
307311
key={day.date.toString()}
308312
className="border-r border-gray-200 last:border-r-0"
309-
data-day-date={dayDate}
313+
{...getDayColumnProps(dayDate)}
310314
>
311315
<div className="h-12 border-b border-gray-200 bg-gray-50 px-3 py-2 text-center">
312316
<div className="text-sm font-semibold text-gray-700">
@@ -319,118 +323,40 @@ function ScheduleView({
319323
const eventProps = calendar.getEventProps(event)
320324
const { style, isSplitEvent } = eventProps
321325

322-
// Original event times (for resize calculations)
323-
const originalStart = event._originalStart ?? event.start
324-
const originalEnd = event._originalEnd ?? event.end
325-
const originalStartDate = originalStart.split('T')[0]
326-
const originalEndDate = originalEnd.split('T')[0]
327-
328-
// Segment times (the actual bounds of THIS day's portion)
329-
// Note: event.start/end are segment bounds, eventProps.start/end are full event bounds
330-
const segmentStart = event.start
331-
const segmentEnd = event.end
332-
const segmentStartDate = segmentStart.split('T')[0]
333-
const segmentEndDate = segmentEnd.split('T')[0]
334-
335-
// First segment: this segment's start date matches the original event's start date
336-
const isFirstSegment = originalStartDate === segmentStartDate
337-
338-
// Last segment: this segment's date matches the original event's end date
339-
const isLastSegment = originalEndDate === segmentStartDate || originalEndDate === segmentEndDate
326+
const segmentInfo = getSegmentInfo(event)
327+
const { isFirstSegment, isLastSegment, originalStart, originalEnd } = segmentInfo
340328

341329
const isBeingResized =
342330
resizeState.isResizing && resizeState.eventId === event.id
343331

344-
// Check if resize is spanning multiple days (mouse moved to a different day than segment)
345-
346-
// Calculate display style based on resize state
347-
let previewStyle: { top: string; height: string } | null = null
348-
let shouldHideSegment = false
349-
350-
if (isBeingResized && resizeState.previewStart && resizeState.previewEnd) {
351-
const previewStartDateStr = resizeState.previewStart.split('T')[0]
352-
const previewEndDateStr = resizeState.previewEnd.split('T')[0]
353-
354-
// Check if this segment's day is within the preview range
355-
const previewAffectsThisDay =
356-
dayDate >= previewStartDateStr && dayDate <= previewEndDateStr
357-
358-
// Check if this segment should be hidden (not in preview range anymore)
359-
// This applies to ANY segment of the event being resized, not just the edge being resized
360-
const isBeingShrunkAway = !previewAffectsThisDay
361-
362-
// Check if the preview has actually changed from the original event bounds
363-
const hasPreviewChanged =
364-
resizeState.previewStart !== originalStart ||
365-
resizeState.previewEnd !== originalEnd
366-
367-
if (isBeingShrunkAway) {
368-
// This segment is being removed by the resize, hide it
369-
shouldHideSegment = true
370-
} else if (hasPreviewChanged) {
371-
// This segment is within the preview range - calculate its new bounds
372-
const isPreviewFirstDay = previewStartDateStr === dayDate
373-
const isPreviewLastDay = previewEndDateStr === dayDate
374-
375-
// Calculate the effective start and end for this segment in the preview
376-
let effectiveStart: string
377-
let effectiveEnd: string
378-
379-
if (isPreviewFirstDay && isPreviewLastDay) {
380-
// Single day event - use exact preview times
381-
effectiveStart = resizeState.previewStart
382-
effectiveEnd = resizeState.previewEnd
383-
} else if (isPreviewFirstDay) {
384-
// First day of multi-day - start at preview start, end at end of day
385-
effectiveStart = resizeState.previewStart
386-
effectiveEnd = `${dayDate}T23:59:59`
387-
} else if (isPreviewLastDay) {
388-
// Last day of multi-day - start at beginning of day, end at preview end
389-
effectiveStart = `${dayDate}T00:00:00`
390-
effectiveEnd = resizeState.previewEnd
391-
} else {
392-
// Middle day - full day
393-
effectiveStart = `${dayDate}T00:00:00`
394-
effectiveEnd = `${dayDate}T23:59:59`
395-
}
396-
397-
// Calculate preview style from effective times
398-
const startDate = new Date(effectiveStart)
399-
const endDate = new Date(effectiveEnd)
400-
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes()
401-
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes() || MINUTES_IN_DAY
402-
const topPercent = (startMinutes / MINUTES_IN_DAY) * 100
403-
const heightPercent = ((endMinutes - startMinutes) / MINUTES_IN_DAY) * 100
404-
405-
if (heightPercent > 0) {
406-
previewStyle = {
407-
top: `${topPercent}%`,
408-
height: `${Math.max(heightPercent, (30 / MINUTES_IN_DAY) * 100)}%`,
409-
}
410-
}
411-
}
412-
}
413-
414-
// Skip rendering if segment is being shrunk away
415-
if (shouldHideSegment) {
332+
// Calculate preview state using library function
333+
const resizePreview = isBeingResized && resizeState.previewStart && resizeState.previewEnd
334+
? calculateSegmentResizePreview({
335+
dayDate,
336+
originalStart,
337+
originalEnd,
338+
previewStart: resizeState.previewStart,
339+
previewEnd: resizeState.previewEnd,
340+
})
341+
: null
342+
343+
if (resizePreview?.shouldHide) {
416344
return null
417345
}
418346

419-
const displayStyle = previewStyle
420-
? { ...style, ...previewStyle }
347+
const displayStyle = resizePreview?.previewStyle
348+
? { ...style, ...resizePreview.previewStyle }
421349
: style
422350

423-
// For multi-day events, show handles on first/last segments
424-
// For single-day events, show both handles
425351
const showTopHandle = !isSplitEvent || isFirstSegment
426-
427-
// Show bottom handle if:
428-
// 1. Not a split event (single day), OR
429-
// 2. This is the last segment (matches original event's end date)
430352
const showBottomHandle = !isSplitEvent || isLastSegment
353+
const isActivelyResized = isBeingResized && resizePreview?.previewStyle !== null
431354

432-
// Check if this segment is being actively resized (original or new target)
433-
const isActivelyResized = isBeingResized && previewStyle !== null
355+
// Format time display using library function
356+
const timeRange = formatEventTimeRange(
357+
isBeingResized && resizeState.previewStart ? resizeState.previewStart : originalStart,
358+
isBeingResized && resizeState.previewEnd ? resizeState.previewEnd : originalEnd,
359+
)
434360

435361
return (
436362
<div
@@ -443,7 +369,6 @@ function ScheduleView({
443369
title={event.title}
444370
style={displayStyle}
445371
onClick={(e) => {
446-
// Don't trigger event click if clicking on resize handle
447372
if (
448373
!resizeState.isResizing &&
449374
!(e.target as HTMLElement).closest('[data-resize-handle]')
@@ -458,130 +383,60 @@ function ScheduleView({
458383
{...getResizeHandleProps(
459384
event.id,
460385
'top',
461-
event._originalStart ?? event.start,
462-
event._originalEnd ?? event.end,
386+
originalStart,
387+
originalEnd,
463388
)}
464389
/>
465390
)}
466391
<div className="font-semibold pt-1">{event.title}</div>
467-
{displayStyle && parseFloat(displayStyle.height) > 2 && (() => {
468-
const startDt = new Date(isBeingResized ? resizeState.previewStart! : originalStart)
469-
const endDt = new Date(isBeingResized ? resizeState.previewEnd! : originalEnd)
470-
const isMultiDay = startDt.toDateString() !== endDt.toDateString()
471-
472-
if (isMultiDay) {
473-
return (
474-
<div className="text-xs opacity-90 mt-0.5">
475-
{startDt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}{' '}
476-
{startDt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
477-
{' - '}
478-
{endDt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}{' '}
479-
{endDt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
480-
</div>
481-
)
482-
}
483-
return (
484-
<div className="text-xs opacity-90 mt-0.5">
485-
{startDt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
486-
{' - '}
487-
{endDt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
488-
</div>
489-
)
490-
})()}
392+
{displayStyle && parseFloat(displayStyle.height) > 2 && (
393+
<div className="text-xs opacity-90 mt-0.5">
394+
{timeRange.rangeFormatted}
395+
</div>
396+
)}
491397
{showBottomHandle && (
492398
<ResizeHandle
493399
edge="bottom"
494400
{...getResizeHandleProps(
495401
event.id,
496402
'bottom',
497-
event._originalStart ?? event.start,
498-
event._originalEnd ?? event.end,
403+
originalStart,
404+
originalEnd,
499405
)}
500406
/>
501407
)}
502408
</div>
503409
)
504410
})}
505-
{/* Ghost preview on ALL days in preview range when resizing across days */}
411+
{/* Ghost preview on days without existing segments */}
506412
{resizeState.isResizing &&
507413
resizeState.previewStart &&
508414
resizeState.previewEnd &&
415+
!day.events.some((e) => e.id === resizeState.eventId) &&
509416
(() => {
510-
const previewStartDate = resizeState.previewStart.split('T')[0]
511-
const previewEndDate = resizeState.previewEnd.split('T')[0]
417+
const ghostStyle = calculateGhostPreviewStyle({
418+
dayDate,
419+
previewStart: resizeState.previewStart,
420+
previewEnd: resizeState.previewEnd,
421+
})
512422

513-
// Check if this day already has a segment of the event being resized
514-
const eventOnThisDay = day.events.some(
515-
(e) => e.id === resizeState.eventId,
423+
if (!ghostStyle) return null
424+
425+
const timeRange = formatEventTimeRange(
426+
resizeState.previewStart,
427+
resizeState.previewEnd,
516428
)
517429

518-
// Check if this day is within the new preview range
519-
const isDayInPreviewRange =
520-
dayDate >= previewStartDate && dayDate <= previewEndDate
521-
522-
// Show ghost on days that are in the preview range but don't have an existing segment
523-
if (!eventOnThisDay && isDayInPreviewRange) {
524-
// Calculate ghost style based on position in the range
525-
const isFirstDay = dayDate === previewStartDate
526-
const isLastDay = dayDate === previewEndDate
527-
528-
let ghostTop: number
529-
let ghostBottom: number
530-
531-
if (isFirstDay && isLastDay) {
532-
// Single day - use actual times
533-
const startDate = new Date(resizeState.previewStart)
534-
const endDate = new Date(resizeState.previewEnd)
535-
ghostTop = (startDate.getHours() * 60 + startDate.getMinutes()) / MINUTES_IN_DAY * 100
536-
ghostBottom = (endDate.getHours() * 60 + endDate.getMinutes()) / MINUTES_IN_DAY * 100
537-
} else if (isFirstDay) {
538-
// First day of multi-day range
539-
const startDate = new Date(resizeState.previewStart)
540-
ghostTop = (startDate.getHours() * 60 + startDate.getMinutes()) / MINUTES_IN_DAY * 100
541-
ghostBottom = 100 // End of day
542-
} else if (isLastDay) {
543-
// Last day of multi-day range
544-
const endDate = new Date(resizeState.previewEnd)
545-
ghostTop = 0 // Start of day
546-
ghostBottom = (endDate.getHours() * 60 + endDate.getMinutes()) / MINUTES_IN_DAY * 100
547-
} else {
548-
// Middle day - full day
549-
ghostTop = 0
550-
ghostBottom = 100
551-
}
552-
553-
const ghostHeight = ghostBottom - ghostTop
554-
if (ghostHeight <= 0) return null
555-
556-
const ghostStyle = {
557-
top: `${ghostTop}%`,
558-
height: `${Math.max(ghostHeight, (30 / MINUTES_IN_DAY) * 100)}%`,
559-
}
560-
561-
// Display the full event date and time (only dates for multi-day)
562-
const startDateTime = new Date(resizeState.previewStart)
563-
const endDateTime = new Date(resizeState.previewEnd)
564-
const isMultiDay = startDateTime.toDateString() !== endDateTime.toDateString()
565-
566-
const ghostStart = isMultiDay
567-
? `${startDateTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} ${startDateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
568-
: startDateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
569-
const ghostEnd = isMultiDay
570-
? `${endDateTime.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} ${endDateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
571-
: endDateTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
572-
573-
return (
574-
<div
575-
className="absolute bg-blue-500/70 text-white rounded px-2 py-1 text-xs font-medium overflow-hidden ring-2 ring-blue-300 z-20"
576-
style={ghostStyle}
577-
>
578-
<div className="font-semibold pt-1 opacity-70">
579-
{ghostStart} - {ghostEnd}
580-
</div>
430+
return (
431+
<div
432+
className="absolute bg-blue-500/70 text-white rounded px-2 py-1 text-xs font-medium overflow-hidden ring-2 ring-blue-300 z-20"
433+
style={ghostStyle}
434+
>
435+
<div className="font-semibold pt-1 opacity-70">
436+
{timeRange.rangeFormatted}
581437
</div>
582-
)
583-
}
584-
return null
438+
</div>
439+
)
585440
})()}
586441
</div>
587442
</div>

packages/react-time/src/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,24 @@ export type {
33
ResizeState,
44
ResizeOptions,
55
UseCalendarOptions,
6-
} from './useCalendar'
6+
} from './useCalendar'
7+
8+
export {
9+
calculateGhostPreviewStyle,
10+
calculateSegmentResizePreview,
11+
formatEventTimeRange,
12+
getEventDisplayTimeRange,
13+
getSegmentInfo,
14+
isMultiDayEvent,
15+
} from '@tanstack/time'
16+
17+
export type {
18+
EventTimeRange,
19+
FormatEventTimeOptions,
20+
FormattedEventTime,
21+
GhostPreviewOptions,
22+
PositionStyle,
23+
ResizePreviewOptions,
24+
SegmentInfo,
25+
SegmentResizePreview,
26+
} from '@tanstack/time'

0 commit comments

Comments
 (0)