Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/features/schedules/lib/date-ranges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,177 @@ describe("buildDateRanges", () => {
end: dayjs.utc("2023-06-12T10:00:00Z").tz(timeZone),
});
});

it("should clip spillover availability when next day is fully unavailable via override", () => {
const timeZone = "Asia/Kolkata"; // UTC+5:30
const dateFrom = dayjs("2026-01-27").tz(timeZone, true);
const dateTo = dayjs("2026-01-30").tz(timeZone, true);

// Simulation of a shift from 9:00 PM to 3:00 AM (next day)
const workingHours = [
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 21, 0)), // 21:00
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)), // 23:59
},
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)), // 00:00
endTime: new Date(Date.UTC(0, 0, 0, 3, 0)), // 03:00
},
];

// Date Override: Jan 28th Unavailable
const overrides = [
{
date: new Date("2026-01-28T00:00:00.000Z"),
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
},
];

const availability = [...workingHours, ...overrides];

const { dateRanges } = buildDateRanges({
availability,
timeZone,
dateFrom,
dateTo,
travelSchedules: [],
});

const rangesTouching28th = dateRanges.filter(
(r) =>
dayjs(r.start).tz(timeZone).format("YYYY-MM-DD") === "2026-01-28" ||
dayjs(r.end).tz(timeZone).format("YYYY-MM-DD") === "2026-01-28"
);

// There should be exactly one range touching 28th (the one ending at midnight)
expect(rangesTouching28th.length).toBe(1);
const clippedRange = rangesTouching28th[0];

// Start of range should be 27th 21:00.
// End should be 28th 00:00.
expect(clippedRange).toEqual({
start: dayjs("2026-01-27T21:00:00Z").tz(timeZone, true),
end: dayjs("2026-01-28T00:00:00Z").tz(timeZone, true),
});
});

it("should clip spillover availability when next day has non-contiguous time override", () => {
const timeZone = "Asia/Kolkata";
const dateFrom = dayjs("2026-01-27").tz(timeZone, true);
const dateTo = dayjs("2026-01-30").tz(timeZone, true);

// Simulation of a shift from 9:00 PM to 3:00 AM (next day)
const workingHours = [
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 21, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)),
},
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 3, 0)),
},
];

// Override on Jan 28th: Only available 10:00 AM - 14:00 PM
const overrides = [
{
date: new Date("2026-01-28T00:00:00.000Z"),
startTime: new Date(Date.UTC(0, 0, 0, 10, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 14, 0)),
},
];

const availability = [...workingHours, ...overrides];

const { dateRanges } = buildDateRanges({
availability,
timeZone,
dateFrom,
dateTo,
travelSchedules: [],
});

// Check 28th. Should ONLY have 10am-2pm. The spillover (00:00-03:00) should be gone.
const rangesOn28th = dateRanges.filter(
(r) =>
dayjs(r.start).tz(timeZone).format("YYYY-MM-DD") === "2026-01-28" ||
dayjs(r.end).tz(timeZone).format("YYYY-MM-DD") === "2026-01-28"
);

// We expect:
// 1. The spillover range (clipped to end at 00:00)
// 2. The override range (10:00 - 14:00).
const clippedSpillover = rangesOn28th.find((r) => dayjs(r.start).tz(timeZone).date() === 27);
const overrideRange = rangesOn28th.find((r) => dayjs(r.start).tz(timeZone).date() === 28);

expect(clippedSpillover).toBeDefined();
expect(clippedSpillover).toEqual({
start: dayjs("2026-01-27T21:00:00Z").tz(timeZone, true),
end: dayjs("2026-01-28T00:00:00Z").tz(timeZone, true),
});

expect(overrideRange).toBeDefined();
expect(overrideRange).toEqual({
start: dayjs("2026-01-28T10:00:00Z").tz(timeZone, true),
end: dayjs("2026-01-28T14:00:00Z").tz(timeZone, true),
});
});

it("should spillover availability to next day when override range is contiguous", () => {
const timeZone = "Asia/Kolkata";
const dateFrom = dayjs("2026-01-27").tz(timeZone, true);
const dateTo = dayjs("2026-01-30").tz(timeZone, true);

// Simulation of a shift from 9:00 PM to 3:00 AM (next day)
const workingHours = [
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 21, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 23, 59)),
},
{
days: [1, 2, 3, 4, 5],
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 3, 0)),
},
];

// Day 2 Override: 00:00 - 00:30 AM
const overrides = [
{
date: new Date("2026-01-28T00:00:00.000Z"),
startTime: new Date(Date.UTC(0, 0, 0, 0, 0)),
endTime: new Date(Date.UTC(0, 0, 0, 0, 30)),
},
];

const availability = [...workingHours, ...overrides];

const { dateRanges } = buildDateRanges({
availability,
timeZone,
dateFrom,
dateTo,
travelSchedules: [],
});

const rangesOnBoundary = dateRanges.filter((r) => {
const start = dayjs(r.start).tz(timeZone);
return start.date() === 27 && start.hour() > 12;
});

// We should have ONE range starting at 21:00 (Day 1) and ending at 00:30 (Day 2).
expect(rangesOnBoundary.length).toBe(1);
expect(rangesOnBoundary[0]).toEqual({
start: dayjs("2026-01-27T21:00:00Z").tz(timeZone, true),
end: dayjs("2026-01-28T00:30:00Z").tz(timeZone, true),
});
});
});

describe("subtract", () => {
Expand Down
43 changes: 39 additions & 4 deletions packages/features/schedules/lib/date-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,45 @@ export function buildDateRanges({
const dateRanges = Object.values({
...groupedWorkingHours,
...groupedDateOverrides,
}).map(
// remove 0-length overrides that were kept to cancel out working dates until now.
(ranges) => ranges.filter((range) => range.start.valueOf() !== range.end.valueOf())
);
})
.flat()
.sort((a, b) => a.start.valueOf() - b.start.valueOf())
.reduce((acc, current) => {
// Step 1: Clip Spillover
// If a range spills into the next day, and that next day has a Date Override,
// clip the range so it ends at midnight.
const startDayStr = current.start.format("YYYY-MM-DD");
const endDayStr = current.end.format("YYYY-MM-DD");
let processedRange = current;

if (startDayStr !== endDayStr && groupedDateOverrides[endDayStr]) {
processedRange = {
...current,
end: current.end.startOf("day"),
};
}

// Step 2: Filter Invalid ranges
// Ensure specific rules (like 0-length ranges) are removed.
if (processedRange.start.valueOf() === processedRange.end.valueOf()) {
return acc;
}

// Step 3: Merge Touching Ranges
// If this range touches or overlaps the previous one, merge them into a single continuous range.
if (acc.length === 0) {
return [processedRange];
}

const last = acc[acc.length - 1];
if (processedRange.start.valueOf() <= last.end.valueOf()) {
last.end = dayjs.max(last.end, processedRange.end);
} else {
acc.push(processedRange);
}

return acc;
}, [] as DateRange[]);

const oooExcludedDateRanges = Object.values({
...groupedWorkingHours,
Expand Down
Loading