fix: Valid timeslot marked 'slot no longer available' (#19590)
* Handle date mismatch b/w schedule key and slot * Add feature ollout support
This commit is contained in:
1 parent
b6caf866c9
commit
0ad5770bb9
11 files changed
+320
-23
No files matched your search
+3
-1
@@ -421,4 +421,6 @@ NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS=
|
||||
# Query available slots interval - Should be kept high in minutes as it could cause significant load on the system
|
||||
NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS=
|
||||
# Used to invalidate available slots when navigating to booking form
|
||||
NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
||||
NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
||||
# Used to enable quick availability checks for x% of all visitors
|
||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
@@ -31,6 +31,7 @@ import { OverlayCalendar } from "./components/OverlayCalendar/OverlayCalendar";
|
||||
import { RedirectToInstantMeetingModal } from "./components/RedirectToInstantMeetingModal";
|
||||
import { BookerSection } from "./components/Section";
|
||||
import { NotFound } from "./components/Unavailable";
|
||||
import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/useIsQuickAvailabilityCheckFeatureEnabled";
|
||||
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
|
||||
import { useBookerStore } from "./store";
|
||||
import type { BookerProps, WrappedBookerProps } from "./types";
|
||||
@@ -129,6 +130,7 @@ const BookerComponent = ({
|
||||
const animationScope = useBookerResizeAnimation(layout, bookerState);
|
||||
|
||||
const timeslotsRef = useRef<HTMLDivElement>(null);
|
||||
const isQuickAvailabilityCheckFeatureEnabled = useIsQuickAvailabilityCheckFeatureEnabled();
|
||||
const StickyOnDesktop = isMobile ? "div" : StickyBox;
|
||||
|
||||
const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm;
|
||||
@@ -189,13 +191,15 @@ const BookerComponent = ({
|
||||
return setBookerState("booking");
|
||||
}, [event, selectedDate, selectedTimeslot, setBookerState, skipConfirmStep, layout, isInstantMeeting]);
|
||||
|
||||
const unavailableTimeSlots = allSelectedTimeslots.filter((slot) => {
|
||||
return !isTimeSlotAvailable({
|
||||
scheduleData: schedule?.data ?? null,
|
||||
slotToCheckInIso: slot,
|
||||
quickAvailabilityChecks: slots.quickAvailabilityChecks,
|
||||
});
|
||||
});
|
||||
const unavailableTimeSlots = isQuickAvailabilityCheckFeatureEnabled
|
||||
? allSelectedTimeslots.filter((slot) => {
|
||||
return !isTimeSlotAvailable({
|
||||
scheduleData: schedule?.data ?? null,
|
||||
slotToCheckInIso: slot,
|
||||
quickAvailabilityChecks: slots.quickAvailabilityChecks,
|
||||
});
|
||||
})
|
||||
: [];
|
||||
|
||||
const slot = getQueryParam("slot");
|
||||
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { PUBLIC_QUICK_AVAILABILITY_ROLLOUT } from "@calcom/lib/constants";
|
||||
|
||||
import { isVisitorWithinPercentage } from "../../utils/isFeatureEnabledForVisitor";
|
||||
|
||||
export const useIsQuickAvailabilityCheckFeatureEnabled = () => {
|
||||
const isQuickAvailabilityCheckFeatureEnabledRef = useRef(
|
||||
isVisitorWithinPercentage({ percentage: PUBLIC_QUICK_AVAILABILITY_ROLLOUT })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("QuickAvailabilityCheck feature enabled:", isQuickAvailabilityCheckFeatureEnabledRef.current);
|
||||
}, []);
|
||||
|
||||
return isQuickAvailabilityCheckFeatureEnabledRef.current;
|
||||
};
|
||||
@@ -15,6 +15,8 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { TIsAvailableOutputSchema } from "@calcom/trpc/server/routers/viewer/slots/isAvailable.schema";
|
||||
|
||||
import { useIsQuickAvailabilityCheckFeatureEnabled } from "./useIsQuickAvailabilityCheckFeatureEnabled";
|
||||
|
||||
export type QuickAvailabilityCheck = TIsAvailableOutputSchema["slots"][number];
|
||||
|
||||
const useQuickAvailabilityChecks = ({
|
||||
@@ -31,6 +33,11 @@ const useQuickAvailabilityChecks = ({
|
||||
// Maintain a cache to ensure previous state is maintained as the request is fetched
|
||||
// It is important because tentatively selecting a new timeslot will cause a new request which is uncached.
|
||||
const cachedQuickAvailabilityChecksRef = useRef<QuickAvailabilityCheck[]>([]);
|
||||
const isQuickAvailabilityCheckFeatureEnabled = useIsQuickAvailabilityCheckFeatureEnabled();
|
||||
|
||||
if (!isQuickAvailabilityCheckFeatureEnabled) {
|
||||
return cachedQuickAvailabilityChecksRef.current;
|
||||
}
|
||||
|
||||
// Create array of slots with their UTC start and end dates
|
||||
const slotsToCheck = timeslotsAsISOString.map((slot) => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getCookie } from "@calcom/lib/cookie";
|
||||
|
||||
import { isVisitorWithinPercentage } from "./isFeatureEnabledForVisitor";
|
||||
|
||||
const generateRandomUuid = () => {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
const mockGetCookie = ({ expectedUid }: { expectedUid: string }) =>
|
||||
//@ts-expect-error - mock implementation
|
||||
getCookie.mockImplementation((name) => {
|
||||
if (name === "uid") {
|
||||
return expectedUid;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/cookie", () => ({
|
||||
getCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("isVisitorWithinPercentage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Test for deterministic behavior
|
||||
it("should provide consistent results for the same visitorId", () => {
|
||||
const testUuids = [
|
||||
"f956595b-0a69-4167-b9c9-a15d6ac445bb",
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
|
||||
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
|
||||
"6ba7b812-9dad-11d1-80b4-00c04fd430c8",
|
||||
];
|
||||
|
||||
// For each UUID, check results are consistent across multiple calls
|
||||
testUuids.forEach((uuid) => {
|
||||
mockGetCookie({ expectedUid: uuid });
|
||||
const result1 = isVisitorWithinPercentage({ percentage: 50 });
|
||||
const result2 = isVisitorWithinPercentage({ percentage: 50 });
|
||||
const result3 = isVisitorWithinPercentage({ percentage: 50 });
|
||||
|
||||
// All results should be the same for the same UUID
|
||||
expect(result1).toBe(result2);
|
||||
expect(result2).toBe(result3);
|
||||
});
|
||||
});
|
||||
|
||||
// Test for correct percentage distribution
|
||||
it("should approximately match the configured percentage", () => {
|
||||
// Generate a large number of random UUIDs to test distribution
|
||||
|
||||
const sampleSize = 10000;
|
||||
const testPercentages = [10, 25, 50, 75, 90];
|
||||
|
||||
testPercentages.forEach((percentage) => {
|
||||
let enabledCount = 0;
|
||||
|
||||
// Run test with many random UUIDs
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const uuid = generateRandomUuid();
|
||||
mockGetCookie({ expectedUid: uuid });
|
||||
if (isVisitorWithinPercentage({ percentage })) {
|
||||
enabledCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the actual percentage
|
||||
const actualPercentage = (enabledCount / sampleSize) * 100;
|
||||
// We allow a 3% margin of error given the sample size
|
||||
expect(actualPercentage).toBeGreaterThanOrEqual(percentage - 3);
|
||||
expect(actualPercentage).toBeLessThanOrEqual(percentage + 3);
|
||||
});
|
||||
});
|
||||
|
||||
// Test for fallback to random behavior when no UUID is provided
|
||||
it("should not enable feature when UUID is notavailable", () => {
|
||||
mockGetCookie({ expectedUid: undefined });
|
||||
expect(isVisitorWithinPercentage({ percentage: 50 })).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable the feature when percentage is 0", () => {
|
||||
const uuids = Array.from({ length: 100 }, () => generateRandomUuid());
|
||||
uuids.forEach((uuid) => {
|
||||
mockGetCookie({ expectedUid: uuid });
|
||||
expect(isVisitorWithinPercentage({ percentage: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should enable the feature when percentage is 100", () => {
|
||||
const uuids = Array.from({ length: 100 }, () => generateRandomUuid());
|
||||
uuids.forEach((uuid) => {
|
||||
mockGetCookie({ expectedUid: uuid });
|
||||
expect(isVisitorWithinPercentage({ percentage: 100 })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getCookie } from "@calcom/lib/cookie";
|
||||
|
||||
/**
|
||||
* NOTE: Feature can be easily rolled back by setting percentage to 0
|
||||
*/
|
||||
export const isVisitorWithinPercentage = ({ percentage }: { percentage: number }) => {
|
||||
// E2E tests must test all features regardless of their rollout percentage
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
return true;
|
||||
}
|
||||
// TODO: The cookie is currently set when a timeslot is selected but we plan to create it on visitor's visit itself
|
||||
// Current purpose is to identify the visitor who reserved a timeslot but could be used for feature rollout and other UX improvements(like if duplicate booking is attempted by the same person)
|
||||
const visitorId = getCookie("uid");
|
||||
if (!visitorId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the visitor UUID to deterministically generate a number between 0-100
|
||||
// We'll use the last 4 characters of the UUID as they should be sufficiently random
|
||||
const lastFourChars = visitorId.slice(-4);
|
||||
// Convert hex to decimal and take modulo 100 to get a number between 0-99
|
||||
const deterministicNumber = parseInt(lastFourChars, 16) % 100;
|
||||
|
||||
// Enable if this number falls within the percentage
|
||||
return deterministicNumber < percentage;
|
||||
};
|
||||
@@ -57,6 +57,74 @@ describe("isTimeSlotAvailable", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
describe("Different day due to timezone", () => {
|
||||
it("should correctly lookup on appropriate date when the day in which to show the slot is before the date in the ISO string", () => {
|
||||
const slotToCheckInIso1 = "2024-02-08T10:30:00.000Z";
|
||||
const dateToShowSlot1 = "2024-02-07";
|
||||
const quickAvailabilityChecks1: QuickAvailabilityCheck[] = [];
|
||||
|
||||
const result1 = isTimeSlotAvailable({
|
||||
scheduleData: {
|
||||
slots: {
|
||||
[dateToShowSlot1]: [{ time: slotToCheckInIso1 }],
|
||||
},
|
||||
},
|
||||
slotToCheckInIso: slotToCheckInIso1,
|
||||
quickAvailabilityChecks: quickAvailabilityChecks1,
|
||||
});
|
||||
|
||||
expect(result1).toBe(true);
|
||||
|
||||
const slotToCheckInIso2 = "2024-02-01T10:30:00.000Z";
|
||||
const dateToShowSlot2 = "2024-01-31";
|
||||
const quickAvailabilityChecks2: QuickAvailabilityCheck[] = [];
|
||||
const result2 = isTimeSlotAvailable({
|
||||
scheduleData: {
|
||||
slots: {
|
||||
[dateToShowSlot2]: [{ time: slotToCheckInIso2 }],
|
||||
},
|
||||
},
|
||||
slotToCheckInIso: slotToCheckInIso2,
|
||||
quickAvailabilityChecks: quickAvailabilityChecks2,
|
||||
});
|
||||
|
||||
expect(result2).toBe(true);
|
||||
});
|
||||
|
||||
it("should correctly lookup on appropriate date when the day in which to show the slot is after the date in the ISO string", () => {
|
||||
const slotToCheckInIso1 = "2024-02-08T10:30:00.000Z";
|
||||
const dateToShowSlot1 = "2024-02-09";
|
||||
const quickAvailabilityChecks1: QuickAvailabilityCheck[] = [];
|
||||
|
||||
const result1 = isTimeSlotAvailable({
|
||||
scheduleData: {
|
||||
slots: {
|
||||
[dateToShowSlot1]: [{ time: slotToCheckInIso1 }],
|
||||
},
|
||||
},
|
||||
slotToCheckInIso: slotToCheckInIso1,
|
||||
quickAvailabilityChecks: quickAvailabilityChecks1,
|
||||
});
|
||||
|
||||
expect(result1).toBe(true);
|
||||
|
||||
const slotToCheckInIso2 = "2024-02-01T10:30:00.000Z";
|
||||
const dateToShowSlot2 = "2024-01-31";
|
||||
const quickAvailabilityChecks2: QuickAvailabilityCheck[] = [];
|
||||
const result2 = isTimeSlotAvailable({
|
||||
scheduleData: {
|
||||
slots: {
|
||||
[dateToShowSlot2]: [{ time: slotToCheckInIso2 }],
|
||||
},
|
||||
},
|
||||
slotToCheckInIso: slotToCheckInIso2,
|
||||
quickAvailabilityChecks: quickAvailabilityChecks2,
|
||||
});
|
||||
|
||||
expect(result2).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return true when the slot exists in the schedule data", () => {
|
||||
const slotToCheckInIso = "2024-02-08T10:30:00.000Z";
|
||||
const quickAvailabilityChecks: QuickAvailabilityCheck[] = [];
|
||||
|
||||
@@ -1,6 +1,68 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import type { QuickAvailabilityCheck } from "../components/hooks/useSlots";
|
||||
import { isSlotEquivalent, isValidISOFormat } from "./isSlotEquivalent";
|
||||
|
||||
type Maybe<T> = T | undefined;
|
||||
|
||||
// Format is YYYY-MM-DD
|
||||
type DateInBookerTimeZone = string;
|
||||
|
||||
// Format is YYYY-MM-DDTHH:mm:ssZ
|
||||
type SlotInIsoFormat = string;
|
||||
type SlotsInIso = { time: SlotInIsoFormat }[];
|
||||
type ScheduleData = {
|
||||
/**
|
||||
* `slots` is a map of date in Booker's timezone to an array of time slots in ISO format
|
||||
* So, `DateInBookerTimeZone` could be on a different day number than the slot date in ISO format
|
||||
* For example, if Booker's timezone is UTC+5:30, and timeslot is 2025-01-02T21:00:00Z, then `DateInBookerTimeZone` could be 2025-01-03 as it is 02:30 AM next day in IST
|
||||
*/
|
||||
slots: Record<DateInBookerTimeZone, SlotsInIso>;
|
||||
};
|
||||
|
||||
function _isSlotPresent(slotsInIsoForDate: Maybe<SlotsInIso>, slotToCheckInIso: SlotInIsoFormat) {
|
||||
if (!slotsInIsoForDate) return false;
|
||||
// Check if the exact time slot exists in the available slots
|
||||
return slotsInIsoForDate.some((slot) => {
|
||||
return isSlotEquivalent({ slotTimeInIso: slot.time, slotToCheckInIso });
|
||||
});
|
||||
}
|
||||
|
||||
function _isSlotPresentInSchedule({
|
||||
scheduleData,
|
||||
dateInGMT,
|
||||
slotToCheckInIso,
|
||||
}: {
|
||||
scheduleData: ScheduleData;
|
||||
/**
|
||||
* It is just the date in format YYYY-MM-DD
|
||||
*/
|
||||
dateInGMT: string;
|
||||
slotToCheckInIso: SlotInIsoFormat;
|
||||
}) {
|
||||
// Check if the slot is present under the date, previous date and next date
|
||||
// Timezones can't be more than 1 day apart, so we can safely assume that previous and next day covers all the dates where a time slot can be present
|
||||
// We don't want to look up for a slot over all the dates in the schedule data unnecessarily for performance reasons
|
||||
const dateBefore = dayjs(dateInGMT).subtract(1, "day").format("YYYY-MM-DD");
|
||||
const dateAfter = dayjs(dateInGMT).add(1, "day").format("YYYY-MM-DD");
|
||||
|
||||
// No matter what timezone the booker is in, the slot has to be in one of these three dates
|
||||
const slotsInIsoForDate = scheduleData.slots[dateInGMT] as Maybe<SlotsInIso>;
|
||||
const slotsInIsoForDateBefore = scheduleData.slots[dateBefore] as Maybe<SlotsInIso>;
|
||||
const slotsInIsoForDateAfter = scheduleData.slots[dateAfter] as Maybe<SlotsInIso>;
|
||||
|
||||
const matchFoundOnDate = _isSlotPresent(slotsInIsoForDate, slotToCheckInIso);
|
||||
if (matchFoundOnDate) return true;
|
||||
|
||||
const matchFoundOnDateBefore = _isSlotPresent(slotsInIsoForDateBefore, slotToCheckInIso);
|
||||
if (matchFoundOnDateBefore) return true;
|
||||
|
||||
const matchFoundOnDateAfter = _isSlotPresent(slotsInIsoForDateAfter, slotToCheckInIso);
|
||||
if (matchFoundOnDateAfter) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given time slot is available in the schedule
|
||||
* It should never give false negative, false positives are fine.
|
||||
@@ -12,10 +74,8 @@ export const isTimeSlotAvailable = ({
|
||||
slotToCheckInIso,
|
||||
quickAvailabilityChecks,
|
||||
}: {
|
||||
scheduleData: {
|
||||
slots: Record<string, { time: string }[]>;
|
||||
} | null;
|
||||
slotToCheckInIso: string;
|
||||
scheduleData: ScheduleData | null;
|
||||
slotToCheckInIso: SlotInIsoFormat;
|
||||
quickAvailabilityChecks: QuickAvailabilityCheck[];
|
||||
}) => {
|
||||
const isUnavailableAsPerQuickCheck =
|
||||
@@ -31,19 +91,14 @@ export const isTimeSlotAvailable = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
const dateInIso = isValidISOFormat(slotToCheckInIso) ? slotToCheckInIso.split("T")[0] : null;
|
||||
const dateInGMT = isValidISOFormat(slotToCheckInIso) ? slotToCheckInIso.split("T")[0] : null;
|
||||
// If the date is not in ISO format, we could errorneously consider the slot unavailable, so be on the safe side and consider it available
|
||||
// Though this could be a false positive, it's better to consider the slot available than unavailable
|
||||
if (!dateInIso) return true;
|
||||
if (!dateInGMT) return true;
|
||||
|
||||
// Get the slots for this date
|
||||
const slotsForDateInIso = scheduleData.slots[dateInIso];
|
||||
if (!slotsForDateInIso) return false;
|
||||
|
||||
// Check if the exact time slot exists in the available slots
|
||||
const matchFound = slotsForDateInIso.some((slot) => {
|
||||
return isSlotEquivalent({ slotTimeInIso: slot.time, slotToCheckInIso });
|
||||
return _isSlotPresentInSchedule({
|
||||
scheduleData,
|
||||
dateInGMT,
|
||||
slotToCheckInIso,
|
||||
});
|
||||
|
||||
return matchFound;
|
||||
};
|
||||
@@ -76,6 +76,10 @@ export const PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS =
|
||||
export const PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM =
|
||||
process.env.NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM === "1";
|
||||
|
||||
// Enables a feature for x% of all visitors. Takes a number between 0 and 100.
|
||||
export const PUBLIC_QUICK_AVAILABILITY_ROLLOUT =
|
||||
parseInt(process.env.NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT ?? "", 10) || 0;
|
||||
|
||||
export const CURRENT_TIMEZONE = dayjs.tz.guess() !== "Etc/Unknown" ? dayjs.tz.guess() : "Europe/London";
|
||||
|
||||
/** @deprecated use `WEBAPP_URL` */
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getCookie = (name: string) => {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.find((cookie) => cookie.trim().startsWith(`${name}=`))
|
||||
?.split("=")[1];
|
||||
};
|
||||
@@ -447,6 +447,7 @@
|
||||
"NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS",
|
||||
"NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS",
|
||||
"NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM",
|
||||
"NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT",
|
||||
"NEXT_PUBLIC_HEAD_SCRIPTS",
|
||||
"NEXT_PUBLIC_BODY_SCRIPTS",
|
||||
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
|
||||
|
||||
Reference in new issue
Block a user