-
Notifications
You must be signed in to change notification settings - Fork 8.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: Multiple rr-hosts combine to create erroneous availability #18772
base: main
Are you sure you want to change the base?
Changes from 8 commits
11baee3
0e10874
abc4b70
371c197
a72f030
1d59916
cf5cdd5
6407247
7d50b48
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,179 @@ | ||
import { describe, it, expect } from "vitest"; | ||
|
||
import type { Dayjs } from "@calcom/dayjs"; | ||
import dayjs from "@calcom/dayjs"; | ||
|
||
import { getAggregatedAvailability } from "."; | ||
|
||
// Helper to check if a time range overlaps with availability | ||
const isAvailable = (availability: { start: Dayjs; end: Dayjs }[], range: { start: Dayjs; end: Dayjs }) => { | ||
return availability.some(({ start, end }) => { | ||
return start <= range.start && end >= range.end; | ||
}); | ||
}; | ||
|
||
describe("getAggregatedAvailability", () => { | ||
// rr-host availability used to combine into erroneous slots, this confirms it no longer happens | ||
it("should have no host available between 11:00 and 11:30 on January 23, 2025", () => { | ||
const userAvailability = [ | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") }, | ||
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
]; | ||
|
||
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); | ||
const timeRangeToCheckBusy = { | ||
start: dayjs("2025-01-23T11:00:00.000Z"), | ||
end: dayjs("2025-01-23T11:30:00.000Z"), | ||
}; | ||
|
||
expect(isAvailable(result, timeRangeToCheckBusy)).toBe(false); | ||
|
||
const timeRangeToCheckAvailable = { | ||
start: dayjs("2025-01-23T11:00:00.000Z"), | ||
end: dayjs("2025-01-23T11:20:00.000Z"), | ||
}; | ||
|
||
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true); | ||
}); | ||
// validates fixed host behaviour, they all have to be available | ||
it("should only have all fixed hosts available between 11:15 and 11:20 on January 23, 2025", () => { | ||
const userAvailability = [ | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:20:00.000Z") }, | ||
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") }, | ||
], | ||
user: { isFixed: true }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:15:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
], | ||
user: { isFixed: true }, | ||
}, | ||
]; | ||
|
||
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); | ||
const timeRangeToCheckBusy = { | ||
start: dayjs("2025-01-23T11:00:00.000Z"), | ||
end: dayjs("2025-01-23T11:30:00.000Z"), | ||
}; | ||
|
||
expect(isAvailable(result, timeRangeToCheckBusy)).toBe(false); | ||
|
||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:15:00.000Z").format()); | ||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T11:20:00.000Z").format()); | ||
}); | ||
|
||
// Combines rr hosts and fixed hosts, both fixed and one of the rr hosts has to be available for the whole period | ||
// All fixed user ranges are merged with each rr-host | ||
it("Fixed hosts and at least one rr host available between 11:00-11:30 & 12:30-13:00 on January 23, 2025", () => { | ||
// Both fixed user A and B are available 11:00-11:30 & 12:30-13:00 & 13:15-13:30 | ||
// Only user C (rr) is available 11:00-11:30 and only user D (rr) is available 12:30-13:00 | ||
// No rr users are available 13:15-13:30 and this date range should not be a result. | ||
const userAvailability = [ | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") }, | ||
], | ||
user: { isFixed: true }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:20:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
], | ||
user: { isFixed: true }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
]; | ||
|
||
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); | ||
const timeRangeToCheckAvailable = { | ||
start: dayjs("2025-01-23T11:00:00.000Z"), | ||
end: dayjs("2025-01-23T11:30:00.000Z"), | ||
}; | ||
|
||
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true); | ||
|
||
expect(result[0].start.format()).toEqual(dayjs("2025-01-23T11:00:00.000Z").format()); | ||
expect(result[0].end.format()).toEqual(dayjs("2025-01-23T11:30:00.000Z").format()); | ||
expect(result[1].start.format()).toEqual(dayjs("2025-01-23T12:30:00.000Z").format()); | ||
expect(result[1].end.format()).toEqual(dayjs("2025-01-23T13:00:00.000Z").format()); | ||
}); | ||
|
||
it("does not duplicate slots when multiple rr-hosts offer the same availability", () => { | ||
const userAvailability = [ | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T12:30:00.000Z"), end: dayjs("2025-01-23T13:00:00.000Z") }, | ||
{ start: dayjs("2025-01-23T13:15:00.000Z"), end: dayjs("2025-01-23T13:30:00.000Z") }, | ||
{ start: dayjs("2025-01-23T16:10:00.000Z"), end: dayjs("2025-01-23T16:30:00.000Z") }, | ||
], | ||
user: { isFixed: true }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
{ | ||
dateRanges: [], | ||
oooExcludedDateRanges: [ | ||
{ start: dayjs("2025-01-23T11:00:00.000Z"), end: dayjs("2025-01-23T11:30:00.000Z") }, | ||
], | ||
user: { isFixed: false }, | ||
}, | ||
]; | ||
|
||
const result = getAggregatedAvailability(userAvailability, "ROUND_ROBIN"); | ||
const timeRangeToCheckAvailable = { | ||
start: dayjs("2025-01-23T11:00:00.000Z"), | ||
end: dayjs("2025-01-23T11:30:00.000Z"), | ||
}; | ||
|
||
expect(isAvailable(result, timeRangeToCheckAvailable)).toBe(true); | ||
expect(result.length).toBe(1); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -92,6 +92,60 @@ describe("Tests the date-range slot logic", () => { | |
}); | ||
expect(result).toHaveLength(72); | ||
}); | ||
|
||
it("can create multiple time slot groups when multiple date ranges are given", async () => { | ||
const nextDay = dayjs.utc().add(1, "day").startOf("day"); | ||
const dateRanges = [ | ||
// 11:00-11:20,11:20-11:40,11:40-12:00 | ||
{ | ||
start: nextDay.hour(11), | ||
end: nextDay.hour(12), | ||
}, | ||
// 14:00-14:20,14:20-14:40,14:40-15:00 | ||
{ | ||
start: nextDay.hour(14), | ||
end: nextDay.hour(15), | ||
}, | ||
]; | ||
const result = getSlots({ | ||
inviteeDate: nextDay, | ||
frequency: 20, | ||
minimumBookingNotice: 0, | ||
dateRanges: dateRanges, | ||
eventLength: 20, | ||
offsetStart: 0, | ||
organizerTimeZone: "America/Toronto", | ||
}); | ||
|
||
expect(result).toHaveLength(6); | ||
}); | ||
|
||
it("can merge multiple time slot groups when multiple date ranges are given that overlap", async () => { | ||
const nextDay = dayjs.utc().add(1, "day").startOf("day"); | ||
const dateRanges = [ | ||
// 11:00-11:20,11:20-11:40,11:40-12:00 | ||
{ | ||
start: nextDay.hour(11), | ||
end: nextDay.hour(12), | ||
}, | ||
// 12:00-12:20,12:20-12:40 | ||
{ | ||
start: nextDay.hour(11).minute(20), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before this resulted in 7 slots, due to 11:20/11:40 overlap with dateRanges[0]. This is now corrected to the correct (5) |
||
end: nextDay.hour(12).minute(40), | ||
}, | ||
]; | ||
const result = getSlots({ | ||
inviteeDate: nextDay, | ||
frequency: 20, | ||
minimumBookingNotice: 0, | ||
dateRanges: dateRanges, | ||
eventLength: 20, | ||
offsetStart: 0, | ||
organizerTimeZone: "America/Toronto", | ||
}); | ||
|
||
expect(result).toHaveLength(5); | ||
}); | ||
}); | ||
|
||
describe("Tests the slot logic", () => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -164,15 +164,19 @@ function buildSlotsWithDateRanges({ | |
frequency = minimumOfOne(frequency); | ||
eventLength = minimumOfOne(eventLength); | ||
offsetStart = offsetStart ? minimumOfOne(offsetStart) : 0; | ||
const slots: { | ||
time: Dayjs; | ||
userIds?: number[]; | ||
away?: boolean; | ||
fromUser?: IFromUser; | ||
toUser?: IToUser; | ||
reason?: string; | ||
emoji?: string; | ||
}[] = []; | ||
// there can only ever be one slot at a given start time, and based on duration also only a single length. | ||
const slots = new Map< | ||
string, | ||
{ | ||
time: Dayjs; | ||
userIds?: number[]; | ||
away?: boolean; | ||
fromUser?: IFromUser; | ||
toUser?: IToUser; | ||
reason?: string; | ||
emoji?: string; | ||
} | ||
>(); | ||
|
||
let interval = Number(process.env.NEXT_PUBLIC_AVAILABILITY_SCHEDULE_INTERVAL) || 1; | ||
const intervalsWithDefinedStartTimes = [60, 30, 20, 15, 10, 5]; | ||
|
@@ -233,12 +237,12 @@ function buildSlotsWithDateRanges({ | |
}; | ||
} | ||
|
||
slots.push(slotData); | ||
slots.set(slotData.time.toISOString(), slotData); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Key based on start time (same as rendering in Booker) |
||
slotStartTime = slotStartTime.add(frequency + (offsetStart ?? 0), "minutes"); | ||
} | ||
}); | ||
|
||
return slots; | ||
return Array.from(slots.values()); | ||
} | ||
|
||
function fromIndex<T>(cb: (val: T, i: number, a: T[]) => boolean, index: number) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overlapping time slots weren't possible before as date ranges were merged, even rr hosts. Now they are possible because different rr hosts may have overlapping date ranges but we still want to handle individually.