1- import { useCalendar } from '@tanstack/react-time'
1+ import {
2+ calculateGhostPreviewStyle ,
3+ calculateSegmentResizePreview ,
4+ formatEventTimeRange ,
5+ getSegmentInfo ,
6+ useCalendar ,
7+ } from '@tanstack/react-time'
28import ReactDOM from 'react-dom/client'
39import { useState } from 'react'
410import type { Day , Event , Resource } from '@tanstack/time'
@@ -229,8 +235,6 @@ function EventModal({
229235 )
230236}
231237
232- const MINUTES_IN_DAY = 24 * 60
233-
234238interface 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 >
0 commit comments