Skip to content

Commit 049aa15

Browse files
feat: availability rules
1 parent 5169c0f commit 049aa15

3 files changed

Lines changed: 213 additions & 32 deletions

File tree

examples/react/basic/src/index.tsx

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,30 @@ function formatDateToISO(date: Date): string {
3232
return `${year}-${month}-${day}`
3333
}
3434

35+
const sampleResources: Array<Resource> = [
36+
{
37+
id: '1',
38+
label: 'Resource 1',
39+
availability: [
40+
{
41+
weekdays: [1, 2, 3],
42+
startTime: '08:00',
43+
endTime: '17:00',
44+
},
45+
{
46+
weekdays: [4, 5],
47+
startTime: '00:00',
48+
endTime: '24:00',
49+
},
50+
{
51+
weekdays: [6, 7],
52+
startTime: '10:00',
53+
endTime: '15:00',
54+
},
55+
],
56+
},
57+
]
58+
3559
function getSampleEvents(): Array<Event<Resource>> {
3660
const today = new Date()
3761
const tomorrow = new Date(today)
@@ -52,24 +76,28 @@ function getSampleEvents(): Array<Event<Resource>> {
5276
title: 'Team Meeting',
5377
start: `${formatDateToISO(tomorrow)}T10:00:00`,
5478
end: `${formatDateToISO(tomorrow)}T11:00:00`,
79+
resources: sampleResources,
5580
},
5681
{
5782
id: '2',
5883
title: 'Project Review',
5984
start: `${formatDateToISO(dayAfterTomorrow)}T14:00:00`,
6085
end: `${formatDateToISO(dayAfterTomorrow)}T15:30:00`,
86+
resources: sampleResources,
6187
},
6288
{
6389
id: '3',
6490
title: 'Multi-day Conference',
6591
start: `${formatDateToISO(threeDaysLater)}T09:00:00`,
6692
end: `${formatDateToISO(fourDaysLater)}T17:00:00`,
93+
resources: sampleResources,
6794
},
6895
{
6996
id: '4',
7097
title: 'Lunch Break',
7198
start: `${formatDateToISO(threeDaysLater)}T12:00:00`,
7299
end: `${formatDateToISO(threeDaysLater)}T13:00:00`,
100+
resources: sampleResources,
73101
},
74102
]
75103
}
@@ -349,7 +377,6 @@ function ScheduleView({
349377
resizeState.isResizing &&
350378
resizeState.eventId === event.id
351379

352-
// Calculate preview state using library function
353380
const resizePreview =
354381
isBeingResized &&
355382
resizeState.previewStart &&
@@ -376,7 +403,6 @@ function ScheduleView({
376403
const isActivelyResized =
377404
isBeingResized && resizePreview?.previewStyle !== null
378405

379-
// Format time display using library function
380406
const timeRange = formatEventTimeRange(
381407
isBeingResized && resizeState.previewStart
382408
? resizeState.previewStart
@@ -441,7 +467,6 @@ function ScheduleView({
441467
</div>
442468
)
443469
})}
444-
{/* Ghost preview on days without existing segments */}
445470
{resizeState.isResizing &&
446471
resizeState.previewStart &&
447472
resizeState.previewEnd &&
@@ -494,29 +519,10 @@ function CalendarView() {
494519
initialData: emptyFormData,
495520
})
496521

497-
const resources: Array<Resource> = [
498-
{
499-
id: '1',
500-
label: 'Resource 1',
501-
availability: [
502-
{
503-
weekdays: [1, 2, 3, 4, 5],
504-
startTime: '08:00',
505-
endTime: '17:00',
506-
},
507-
{
508-
weekdays: [6, 7],
509-
startTime: '10:00',
510-
endTime: '15:00',
511-
},
512-
],
513-
},
514-
]
515-
516522
const calendar = useCalendar<Resource, Event<Resource>>({
517523
viewMode: { value: 1, unit: 'month' },
518524
events: sampleEvents,
519-
resources,
525+
resources: sampleResources,
520526
timeZone: 'UTC',
521527
resize: {
522528
enabled: true,

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

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const useCalendar = <
8181
const { resize, ...calendarOptions } = options
8282
const resizeEnabled = resize?.enabled ?? true
8383
const containerHeight = resize?.containerHeight ?? 0
84-
const constraints = resize?.constraints
84+
const resizeConstraints = resize?.constraints
8585

8686
const [calendarCore] = useState(
8787
() => new CalendarCore<TResource, TEvent>(calendarOptions),
@@ -152,7 +152,6 @@ export const useCalendar = <
152152

153153
const targetDayDate = getDayFromPoint(e.clientX) ?? originalDayDate
154154

155-
// Calculate day offset if moved to a different day
156155
let dayOffsetMinutes = 0
157156
if (targetDayDate && targetDayDate !== originalDayDate) {
158157
const originalDate = new Date(originalDayDate + 'T00:00:00')
@@ -168,26 +167,128 @@ export const useCalendar = <
168167

169168
const totalDeltaMinutes = deltaMinutes + dayOffsetMinutes
170169

170+
const event = calendarOptions.events?.find((ev) => ev.id === id)
171+
const resourceIds = event?.resources?.map((r) => r.id)
172+
173+
const getUnavailableMinutesForDay = (dayDate: string) => {
174+
const rawRanges = resourceIds?.length
175+
? calendarCore.getUnavailableRanges(dayDate, {
176+
containerHeight: 1440,
177+
resourceIds,
178+
})
179+
: []
180+
return rawRanges.map((range) => {
181+
const startParts = range.startTime.split(':').map(Number)
182+
const endParts = range.endTime.split(':').map(Number)
183+
return {
184+
startMinutes: (startParts[0] ?? 0) * 60 + (startParts[1] ?? 0),
185+
endMinutes: (endParts[0] ?? 0) * 60 + (endParts[1] ?? 0),
186+
}
187+
})
188+
}
189+
190+
const unavailableRanges = getUnavailableMinutesForDay(targetDayDate)
191+
192+
const originalStartDate = start.split('T')[0] ?? ''
193+
const originalEndDate = end.split('T')[0] ?? ''
194+
195+
let shouldBlockResize = false
196+
197+
if (edge === 'top' && targetDayDate < originalStartDate) {
198+
const rawStartMinutes =
199+
new Date(start).getHours() * 60 +
200+
new Date(start).getMinutes() +
201+
totalDeltaMinutes
202+
const targetStartMinutes = ((rawStartMinutes % 1440) + 1440) % 1440
203+
const currentStartMinutes =
204+
new Date(start).getHours() * 60 + new Date(start).getMinutes()
205+
206+
for (const range of unavailableRanges) {
207+
if (
208+
targetStartMinutes < range.endMinutes &&
209+
range.startMinutes < 1440
210+
) {
211+
shouldBlockResize = true
212+
break
213+
}
214+
}
215+
216+
if (!shouldBlockResize) {
217+
const sourceUnavailableRanges =
218+
getUnavailableMinutesForDay(originalStartDate)
219+
for (const range of sourceUnavailableRanges) {
220+
if (
221+
0 < range.endMinutes &&
222+
range.startMinutes < currentStartMinutes
223+
) {
224+
shouldBlockResize = true
225+
break
226+
}
227+
}
228+
}
229+
} else if (edge === 'bottom' && targetDayDate > originalEndDate) {
230+
const rawEndMinutes =
231+
new Date(end).getHours() * 60 +
232+
new Date(end).getMinutes() +
233+
totalDeltaMinutes
234+
const targetEndMinutes = ((rawEndMinutes % 1440) + 1440) % 1440
235+
const currentEndMinutes =
236+
new Date(end).getHours() * 60 + new Date(end).getMinutes()
237+
238+
const sourceUnavailableRanges =
239+
getUnavailableMinutesForDay(originalEndDate)
240+
for (const range of sourceUnavailableRanges) {
241+
if (
242+
currentEndMinutes < range.endMinutes &&
243+
range.startMinutes < 1440
244+
) {
245+
shouldBlockResize = true
246+
break
247+
}
248+
}
249+
250+
if (!shouldBlockResize) {
251+
for (const range of unavailableRanges) {
252+
if (0 < range.endMinutes && range.startMinutes < targetEndMinutes) {
253+
shouldBlockResize = true
254+
break
255+
}
256+
}
257+
}
258+
}
259+
260+
const effectiveDeltaMinutes = shouldBlockResize
261+
? deltaMinutes
262+
: totalDeltaMinutes
263+
const effectiveUnavailableRanges = shouldBlockResize
264+
? getUnavailableMinutesForDay(originalDayDate)
265+
: unavailableRanges
266+
171267
const result = calculateResizedEvent({
172268
originalStart: start,
173269
originalEnd: end,
174270
edge,
175-
deltaMinutes: totalDeltaMinutes,
271+
deltaMinutes: effectiveDeltaMinutes,
176272
timeZone: calendarOptions.timeZone ?? 'UTC',
177-
constraints,
273+
constraints: {
274+
...resizeConstraints,
275+
unavailableRanges: effectiveUnavailableRanges,
276+
},
178277
})
179278

180279
updateResizeState({
181280
eventId: id,
182281
previewStart: result.start,
183282
previewEnd: result.end,
184-
targetDayDate,
283+
targetDayDate: shouldBlockResize ? originalDayDate : targetDayDate,
185284
})
186285
},
187286
[
188287
containerHeight,
189288
calendarOptions.timeZone,
190-
constraints,
289+
calendarOptions.events,
290+
calendarCore,
291+
resizeConstraints,
191292
getDayFromPoint,
192293
updateResizeState,
193294
],

packages/time/src/calendar/getResizeProps.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ import { Temporal } from '@js-temporal/polyfill'
22

33
export type ResizeEdge = 'top' | 'bottom'
44

5+
export interface UnavailableTimeRange {
6+
/** Start time in minutes from midnight (0-1440) */
7+
startMinutes: number
8+
/** End time in minutes from midnight (0-1440) */
9+
endMinutes: number
10+
}
11+
512
export interface ResizeConstraints {
613
minDurationMinutes?: number
714
snapToMinutes?: number
15+
/** Unavailable time ranges that the event cannot be resized into */
16+
unavailableRanges?: Array<UnavailableTimeRange>
817
}
918

1019
const MINUTES_IN_DAY = 24 * 60
@@ -33,6 +42,19 @@ const DEFAULT_SNAP_TO_MINUTES = 15
3342
const roundToNearestInterval = (minutes: number, interval: number): number =>
3443
Math.round(minutes / interval) * interval
3544

45+
const getMinutesFromMidnight = (zdt: Temporal.ZonedDateTime): number =>
46+
zdt.hour * 60 + zdt.minute
47+
48+
const setMinutesFromMidnight = (
49+
zdt: Temporal.ZonedDateTime,
50+
minutes: number,
51+
): Temporal.ZonedDateTime => {
52+
const clampedMinutes = Math.max(0, Math.min(minutes, MINUTES_IN_DAY - 1))
53+
const hours = Math.floor(clampedMinutes / 60)
54+
const mins = clampedMinutes % 60
55+
return zdt.with({ hour: hours, minute: mins, second: 0, millisecond: 0 })
56+
}
57+
3658
export function calculateResizedEvent(
3759
options: CalculateResizedEventOptions,
3860
): ResizedEventResult {
@@ -48,6 +70,7 @@ export function calculateResizedEvent(
4870
const {
4971
minDurationMinutes = DEFAULT_MIN_DURATION_MINUTES,
5072
snapToMinutes = DEFAULT_SNAP_TO_MINUTES,
73+
unavailableRanges = [],
5174
} = constraints
5275

5376
const startZdt =
@@ -60,18 +83,72 @@ export function calculateResizedEvent(
6083
let newStartZdt = startZdt
6184
let newEndZdt = endZdt
6285

86+
const isEntireDayUnavailable = unavailableRanges.some(
87+
(range) => range.startMinutes === 0 && range.endMinutes >= MINUTES_IN_DAY,
88+
)
89+
6390
if (edge === 'top') {
6491
newStartZdt = startZdt.add({ minutes: snappedDelta })
6592
const maxStartZdt = endZdt.subtract({ minutes: minDurationMinutes })
6693
if (Temporal.ZonedDateTime.compare(newStartZdt, maxStartZdt) > 0) {
6794
newStartZdt = maxStartZdt
6895
}
96+
97+
if (isEntireDayUnavailable) {
98+
const originalStartMinutes = getMinutesFromMidnight(startZdt)
99+
newStartZdt = setMinutesFromMidnight(newStartZdt, originalStartMinutes)
100+
} else {
101+
let newStartMinutes = getMinutesFromMidnight(newStartZdt)
102+
103+
for (const range of unavailableRanges) {
104+
if (
105+
newStartMinutes >= range.startMinutes &&
106+
newStartMinutes < range.endMinutes
107+
) {
108+
if (range.endMinutes < MINUTES_IN_DAY) {
109+
newStartZdt = setMinutesFromMidnight(newStartZdt, range.endMinutes)
110+
newStartMinutes = range.endMinutes
111+
} else if (range.startMinutes > 0) {
112+
const validTime = range.startMinutes - snapToMinutes
113+
if (validTime >= 0) {
114+
newStartZdt = setMinutesFromMidnight(newStartZdt, validTime)
115+
newStartMinutes = validTime
116+
}
117+
}
118+
}
119+
}
120+
}
69121
} else {
70122
newEndZdt = endZdt.add({ minutes: snappedDelta })
71123
const minEndZdt = startZdt.add({ minutes: minDurationMinutes })
72124
if (Temporal.ZonedDateTime.compare(newEndZdt, minEndZdt) < 0) {
73125
newEndZdt = minEndZdt
74126
}
127+
128+
if (isEntireDayUnavailable) {
129+
const originalEndMinutes = getMinutesFromMidnight(endZdt)
130+
newEndZdt = setMinutesFromMidnight(newEndZdt, originalEndMinutes)
131+
} else {
132+
let newEndMinutes = getMinutesFromMidnight(newEndZdt)
133+
134+
for (const range of unavailableRanges) {
135+
if (
136+
newEndMinutes > range.startMinutes &&
137+
newEndMinutes <= range.endMinutes
138+
) {
139+
if (range.startMinutes > 0) {
140+
newEndZdt = setMinutesFromMidnight(newEndZdt, range.startMinutes)
141+
newEndMinutes = range.startMinutes
142+
} else if (range.endMinutes < MINUTES_IN_DAY) {
143+
const validTime = range.endMinutes + snapToMinutes
144+
if (validTime <= MINUTES_IN_DAY) {
145+
newEndZdt = setMinutesFromMidnight(newEndZdt, validTime)
146+
newEndMinutes = validTime
147+
}
148+
}
149+
}
150+
}
151+
}
75152
}
76153

77154
const durationMs = newEndZdt.epochMilliseconds - newStartZdt.epochMilliseconds
@@ -139,9 +216,6 @@ export interface SegmentInfo {
139216
segmentEnd: string
140217
}
141218

142-
/**
143-
* Analyzes a segment to determine its position within a multi-day event
144-
*/
145219
export function getSegmentInfo(event: {
146220
start: string
147221
end: string

0 commit comments

Comments
 (0)