Skip to content

Commit 5375009

Browse files
feat: devtools
1 parent 63b149b commit 5375009

15 files changed

Lines changed: 1748 additions & 1140 deletions

File tree

examples/react/basic/src/index.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
useCalendar,
77
} from '@tanstack/react-time'
88
import ReactDOM from 'react-dom/client'
9-
import { useState } from 'react'
9+
import { useEffect, useState } from 'react'
1010
import { TanStackDevtools } from '@tanstack/react-devtools'
1111
import { timeDevtoolsPlugin } from '@tanstack/react-time-devtools'
12-
import type { Day, Event, Resource } from '@tanstack/time'
12+
import type { Day, Event, Resource, ResizeError } from '@tanstack/time'
1313

1414
import './index.css'
1515

@@ -564,6 +564,68 @@ function ScheduleView({
564564
)
565565
}
566566

567+
function ResizeErrorToast({
568+
error,
569+
onDismiss,
570+
}: {
571+
error: ResizeError
572+
onDismiss: () => void
573+
}) {
574+
useEffect(() => {
575+
const timer = setTimeout(onDismiss, 5000)
576+
return () => clearTimeout(timer)
577+
}, [onDismiss])
578+
579+
return (
580+
<div className="fixed bottom-5 right-5 z-50 animate-in slide-in-from-bottom-2 fade-in duration-200">
581+
<div className="bg-red-950/90 border border-red-700/50 text-red-200 rounded-lg px-4 py-3 shadow-lg max-w-md">
582+
<div className="flex items-start gap-3">
583+
<div className="text-red-400 text-lg"></div>
584+
<div className="flex-1 min-w-0">
585+
<div className="font-semibold text-red-100 mb-1">
586+
Cannot Resize Event
587+
</div>
588+
<div className="text-sm text-red-200/80 mb-2">{error.message}</div>
589+
{error.conflicts && error.conflicts.length > 0 && (
590+
<div className="mt-2 space-y-1">
591+
<div className="text-xs text-red-300/70 font-medium uppercase tracking-wide">
592+
Conflicts:
593+
</div>
594+
{error.conflicts.map((conflict, idx) => (
595+
<div
596+
key={idx}
597+
className="text-xs text-red-200/70 bg-red-950/50 rounded px-2 py-1.5 border border-red-800/30"
598+
>
599+
<div className="font-medium text-red-200/90">
600+
{conflict.date}
601+
</div>
602+
<div className="text-red-300/60">
603+
{conflict.conflictRange.start} -{' '}
604+
{conflict.conflictRange.end}
605+
</div>
606+
<div className="text-red-300/50 mt-0.5">
607+
{conflict.description}
608+
</div>
609+
</div>
610+
))}
611+
</div>
612+
)}
613+
<div className="text-xs text-red-300/60 mt-2">
614+
{error.eventTitle}
615+
</div>
616+
</div>
617+
<button
618+
onClick={onDismiss}
619+
className="text-red-400/60 hover:text-red-200 transition-colors"
620+
>
621+
622+
</button>
623+
</div>
624+
</div>
625+
</div>
626+
)
627+
}
628+
567629
function CalendarView() {
568630
const [modalState, setModalState] = useState<{
569631
isOpen: boolean
@@ -576,6 +638,8 @@ function CalendarView() {
576638
initialData: emptyFormData,
577639
})
578640

641+
const [resizeError, setResizeError] = useState<ResizeError | null>(null)
642+
579643
const calendar = useCalendar<Resource, Event<Resource>>({
580644
viewMode: { value: 1, unit: 'month' },
581645
events: sampleEvents,
@@ -588,6 +652,9 @@ function CalendarView() {
588652
minDurationMinutes: 15,
589653
snapToMinutes: 15,
590654
},
655+
onResizeError: (error) => {
656+
setResizeError(error)
657+
},
591658
},
592659
})
593660

@@ -873,6 +940,13 @@ function CalendarView() {
873940
initialData={modalState.initialData}
874941
mode={modalState.mode}
875942
/>
943+
944+
{resizeError && (
945+
<ResizeErrorToast
946+
error={resizeError}
947+
onDismiss={() => setResizeError(null)}
948+
/>
949+
)}
876950
</div>
877951
)
878952
}

packages/react-time/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ export type {
55
UseCalendarOptions,
66
} from './useCalendar'
77

8+
// Re-export ResizeError and AvailabilityConflict from core package
9+
export type { ResizeError, AvailabilityConflict } from '@tanstack/time'
10+
811
export {
912
calculateGhostPreviewStyle,
1013
calculateSegmentResizePreview,

packages/react-time/src/useCalendar/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export type {
44
ResizeOptions,
55
UseCalendarOptions,
66
} from './useCalendar'
7+
export type { ResizeError } from '@tanstack/time'

packages/react-time/src/useCalendar/useCalendar.ts

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import {
1111
CalendarCore,
1212
calculateDeltaMinutesFromPixels,
1313
calculateResizedEvent,
14+
getTimeClient,
1415
} from '@tanstack/time'
1516
import type {
17+
AvailabilityConflict,
1618
CalendarApi,
1719
CalendarCoreOptions,
1820
Event,
1921
ResizeConstraints,
2022
ResizeEdge,
23+
ResizeError,
2124
Resource,
2225
} from '@tanstack/time'
2326

@@ -36,6 +39,7 @@ export interface ResizeOptions {
3639
constraints?: ResizeConstraints
3740
onResizeStart?: (eventId: string, edge: ResizeEdge) => void
3841
onResizeEnd?: (eventId: string, newStart: string, newEnd: string) => void
42+
onResizeError?: (error: ResizeError) => void
3943
}
4044

4145
interface ResizeHandleHandlers {
@@ -101,6 +105,11 @@ export const useCalendar = <
101105
originalDayDate: string
102106
currentDayDate: string
103107
} | null>(null)
108+
const lastEmittedErrorRef = useRef<{
109+
eventId: string
110+
message: string
111+
timestamp: number
112+
} | null>(null)
104113

105114
const subscribeResize = useCallback((listener: () => void) => {
106115
resizeListenersRef.current.add(listener)
@@ -193,22 +202,52 @@ export const useCalendar = <
193202
const originalEndDate = end.split('T')[0] ?? ''
194203

195204
let shouldBlockResize = false
205+
let blockReason: ResizeError['reason'] = 'blocked'
206+
let blockMessage = 'Resize blocked'
207+
const conflicts: Array<AvailabilityConflict> = []
208+
209+
const formatMinutesToTime = (minutes: number): string => {
210+
const hours = Math.floor(minutes / 60)
211+
const mins = minutes % 60
212+
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`
213+
}
214+
215+
const snapToMinutes = resizeConstraints?.snapToMinutes ?? 1
216+
const snapMinutes = (minutes: number): number => {
217+
if (snapToMinutes <= 1) return minutes
218+
return Math.round(minutes / snapToMinutes) * snapToMinutes
219+
}
196220

197221
if (edge === 'top' && targetDayDate < originalStartDate) {
198222
const rawStartMinutes =
199223
new Date(start).getHours() * 60 +
200224
new Date(start).getMinutes() +
201225
totalDeltaMinutes
202226
const targetStartMinutes = ((rawStartMinutes % 1440) + 1440) % 1440
227+
// Snap to grid before checking conflicts
228+
const snappedTargetStartMinutes = snapMinutes(targetStartMinutes)
203229
const currentStartMinutes =
204230
new Date(start).getHours() * 60 + new Date(start).getMinutes()
205231

206232
for (const range of unavailableRanges) {
207233
if (
208-
targetStartMinutes < range.endMinutes &&
234+
snappedTargetStartMinutes < range.endMinutes &&
209235
range.startMinutes < 1440
210236
) {
211237
shouldBlockResize = true
238+
blockReason = 'unavailable-time'
239+
blockMessage = `Cannot resize: Event would start at ${formatMinutesToTime(snappedTargetStartMinutes)} which is during unavailable time (${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)})`
240+
conflicts.push({
241+
date: targetDayDate,
242+
conflictRange: {
243+
start: formatMinutesToTime(
244+
Math.max(snappedTargetStartMinutes, range.startMinutes),
245+
),
246+
end: formatMinutesToTime(range.endMinutes),
247+
},
248+
resourceIds: resourceIds ?? [],
249+
description: `Target time ${formatMinutesToTime(snappedTargetStartMinutes)} conflicts with unavailable period ${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)}`,
250+
})
212251
break
213252
}
214253
}
@@ -222,6 +261,19 @@ export const useCalendar = <
222261
range.startMinutes < currentStartMinutes
223262
) {
224263
shouldBlockResize = true
264+
blockReason = 'unavailable-time'
265+
blockMessage = `Cannot resize: Would need to pass through unavailable time on ${originalStartDate} (${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)})`
266+
conflicts.push({
267+
date: originalStartDate,
268+
conflictRange: {
269+
start: formatMinutesToTime(range.startMinutes),
270+
end: formatMinutesToTime(
271+
Math.min(range.endMinutes, currentStartMinutes),
272+
),
273+
},
274+
resourceIds: resourceIds ?? [],
275+
description: `Must pass through unavailable period ${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)}`,
276+
})
225277
break
226278
}
227279
}
@@ -232,6 +284,8 @@ export const useCalendar = <
232284
new Date(end).getMinutes() +
233285
totalDeltaMinutes
234286
const targetEndMinutes = ((rawEndMinutes % 1440) + 1440) % 1440
287+
// Snap to grid before checking conflicts
288+
const snappedTargetEndMinutes = snapMinutes(targetEndMinutes)
235289
const currentEndMinutes =
236290
new Date(end).getHours() * 60 + new Date(end).getMinutes()
237291

@@ -243,20 +297,99 @@ export const useCalendar = <
243297
range.startMinutes < 1440
244298
) {
245299
shouldBlockResize = true
300+
blockReason = 'unavailable-time'
301+
blockMessage = `Cannot resize: Would need to pass through unavailable time on ${originalEndDate} (${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)})`
302+
conflicts.push({
303+
date: originalEndDate,
304+
conflictRange: {
305+
start: formatMinutesToTime(
306+
Math.max(range.startMinutes, currentEndMinutes),
307+
),
308+
end: formatMinutesToTime(range.endMinutes),
309+
},
310+
resourceIds: resourceIds ?? [],
311+
description: `Must pass through unavailable period ${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)}`,
312+
})
246313
break
247314
}
248315
}
249316

250317
if (!shouldBlockResize) {
251318
for (const range of unavailableRanges) {
252-
if (0 < range.endMinutes && range.startMinutes < targetEndMinutes) {
319+
if (
320+
0 < range.endMinutes &&
321+
range.startMinutes < snappedTargetEndMinutes
322+
) {
253323
shouldBlockResize = true
324+
blockReason = 'unavailable-time'
325+
blockMessage = `Cannot resize: Event would end at ${formatMinutesToTime(snappedTargetEndMinutes)} which is during unavailable time (${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)})`
326+
conflicts.push({
327+
date: targetDayDate,
328+
conflictRange: {
329+
start: formatMinutesToTime(range.startMinutes),
330+
end: formatMinutesToTime(
331+
Math.min(snappedTargetEndMinutes, range.endMinutes),
332+
),
333+
},
334+
resourceIds: resourceIds ?? [],
335+
description: `Target time ${formatMinutesToTime(snappedTargetEndMinutes)} conflicts with unavailable period ${formatMinutesToTime(range.startMinutes)} - ${formatMinutesToTime(range.endMinutes)}`,
336+
})
254337
break
255338
}
256339
}
257340
}
258341
}
259342

343+
// Emit error if resize is blocked (throttle to avoid emitting on every mouse move)
344+
if (shouldBlockResize) {
345+
const event = calendarOptions.events?.find((ev) => ev.id === id)
346+
const now = Date.now()
347+
const lastError = lastEmittedErrorRef.current
348+
349+
// Only emit if this is a different error or it's been more than 500ms
350+
const shouldEmitError =
351+
!lastError ||
352+
lastError.eventId !== id ||
353+
lastError.message !== blockMessage ||
354+
now - lastError.timestamp > 500
355+
356+
if (shouldEmitError) {
357+
const resizeError: ResizeError = {
358+
eventId: id,
359+
eventTitle: event?.title ?? 'Unknown Event',
360+
reason: blockReason,
361+
message: blockMessage,
362+
originalStart: start,
363+
originalEnd: end,
364+
conflicts: conflicts.length > 0 ? conflicts : undefined,
365+
}
366+
367+
// Emit to TimeClient for devtools
368+
getTimeClient().emit('event:resize:error', {
369+
eventId: id,
370+
eventTitle: event?.title ?? 'Unknown Event',
371+
reason: blockReason,
372+
message: blockMessage,
373+
originalStart: start,
374+
originalEnd: end,
375+
conflicts: conflicts.length > 0 ? conflicts : undefined,
376+
})
377+
378+
// Call user-provided error handler
379+
resize?.onResizeError?.(resizeError)
380+
381+
// Track this error emission
382+
lastEmittedErrorRef.current = {
383+
eventId: id,
384+
message: blockMessage,
385+
timestamp: now,
386+
}
387+
}
388+
} else {
389+
// Clear last error when resize is no longer blocked
390+
lastEmittedErrorRef.current = null
391+
}
392+
260393
const effectiveDeltaMinutes = shouldBlockResize
261394
? deltaMinutes
262395
: totalDeltaMinutes
@@ -313,6 +446,7 @@ export const useCalendar = <
313446
}
314447

315448
originalEventRef.current = null
449+
lastEmittedErrorRef.current = null
316450
updateResizeState(initialResizeState)
317451

318452
document.removeEventListener('mousemove', handleMouseMove)

0 commit comments

Comments
 (0)