Skip to content
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: Valid timeslot marked 'slot no longer available' #19590

Merged
Merged
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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 11 additions & 7 deletions packages/features/bookings/Booker/Booker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading
Loading