From 23b3a6661ca8941142c13e8713611605c69c9077 Mon Sep 17 00:00:00 2001 From: Rob Jackson Date: Wed, 17 May 2023 12:56:55 +0100 Subject: [PATCH 01/42] Add feature to offset start times for event types (#8506) * Add offsetStart column to EventType * Update buildSlots to support offset start times * Add "Offset Start Time" to edit event types form * Fix offset events not appearing on availability selector * Guard against negative offsetStart values * Lock offsetStart field for managed event types * EventLimits UI tweaks for "Offset start times" * EventLimits UI: Fix offsetStart preview not always updating * Remove unnecessary ctx from getSchedule.test.ts --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .../components/eventtype/EventLimitsTab.tsx | 41 ++++++++++- apps/web/pages/event-types/[type]/index.tsx | 3 + apps/web/public/static/locales/en/common.json | 4 ++ apps/web/test/lib/getSchedule.test.ts | 72 +++++++++++++++++-- apps/web/test/lib/slots.test.ts | 27 +++++++ .../components/TeamAvailabilityTimes.tsx | 1 + packages/lib/defaultEvents.ts | 1 + packages/lib/getEventTypeById.ts | 1 + packages/lib/slots.ts | 14 +++- packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/selects/event-types.ts | 1 + packages/prisma/zod-utils.ts | 1 + .../viewer/eventTypes/update.handler.ts | 8 +++ .../trpc/server/routers/viewer/slots/util.ts | 2 + 16 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 packages/prisma/migrations/20230424163718_offset_start_times/migration.sql diff --git a/apps/web/components/eventtype/EventLimitsTab.tsx b/apps/web/components/eventtype/EventLimitsTab.tsx index c24315c407..ec067d2a6c 100644 --- a/apps/web/components/eventtype/EventLimitsTab.tsx +++ b/apps/web/components/eventtype/EventLimitsTab.tsx @@ -108,7 +108,7 @@ const MinimumBookingNoticeInput = React.forwardRef< }); export const EventLimitsTab = ({ eventType }: Pick) => { - const { t } = useLocale(); + const { t, i18n } = useLocale(); const formMethods = useFormContext(); const PERIOD_TYPES = [ @@ -149,12 +149,25 @@ export const EventLimitsTab = ({ eventType }: Pick offsetStartValue > 0); + + // Preview how the offset will affect start times + const offsetOriginalTime = new Date(); + offsetOriginalTime.setHours(9, 0, 0, 0); + const offsetAdjustedTime = new Date(offsetOriginalTime.getTime() + offsetStartValue * 60 * 1000); + return (
@@ -432,6 +445,32 @@ export const EventLimitsTab = ({ eventType }: Pick )} /> +
+ { + setOffsetToggle(active); + if (!active) { + formMethods.setValue("offsetStart", 0); + } + }}> + {t("minutes")}} + hint={t("offset_start_description", { + originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }), + adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }), + })} + /> +
); }; diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 1c22818d1d..f9dff7ea9e 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -51,6 +51,7 @@ export type FormValues = { eventName: string; slug: string; length: number; + offsetStart: number; description: string; disableGuests: boolean; requiresConfirmation: boolean; @@ -211,6 +212,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { bookingLimits: eventType.bookingLimits || undefined, durationLimits: eventType.durationLimits || undefined, length: eventType.length, + offsetStart: eventType.offsetStart, hidden: eventType.hidden, periodDates: { startDate: periodDates.startDate, @@ -255,6 +257,7 @@ const EventTypePage = (props: EventTypeSetupProps) => { ) .optional(), length: z.union([z.string().transform((val) => +val), z.number()]).optional(), + offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(), bookingFields: eventTypeBookingFields, }) // TODO: Add schema for other fields later. diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index de7b68f9f0..b57f17a687 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -756,6 +756,10 @@ "new_event_type_to_book_description": "Create a new event type for people to book times with.", "length": "Length", "minimum_booking_notice": "Minimum Notice", + "offset_toggle": "Offset start times", + "offset_toggle_description": "Offset timeslots shown to bookers by a specified number of minutes", + "offset_start": "Offset by", + "offset_start_description": "e.g. this will show time slots to your bookers at {{ adjustedTime }} instead of {{ originalTime }}", "slot_interval": "Time-slot intervals", "slot_interval_default": "Use event length (default)", "delete_event_type": "Delete event type?", diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 78a02098f0..f9aab9ea93 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -151,10 +151,6 @@ const TestData = { }, }; -const ctx = { - prisma: prismaMock, -}; - type App = { slug: string; dirName: string; @@ -186,6 +182,7 @@ type InputEventType = { id: number; title?: string; length?: number; + offsetStart?: number; slotInterval?: number; minimumBookingNotice?: number; users?: { id: number }[]; @@ -711,6 +708,72 @@ describe("getSchedule", () => { ); }); + test("Start times are offset (offsetStart)", async () => { + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); + + const scenarioData = { + eventTypes: [ + { + id: 1, + length: 25, + offsetStart: 5, + users: [ + { + id: 101, + }, + ], + }, + ], + users: [ + { + ...TestData.users.example, + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }, + ], + hosts: [], + apps: [TestData.apps.googleCalendar], + }; + + createBookingScenario(scenarioData); + + const schedule = await getSchedule({ + eventTypeId: 1, + eventTypeSlug: "", + startTime: `${plus1DateString}T18:30:00.000Z`, + endTime: `${plus2DateString}T18:29:59.999Z`, + timeZone: Timezones["+5:30"], + }); + + expect(schedule).toHaveTimeSlots( + [ + `04:05:00.000Z`, + `04:35:00.000Z`, + `05:05:00.000Z`, + `05:35:00.000Z`, + `06:05:00.000Z`, + `06:35:00.000Z`, + `07:05:00.000Z`, + `07:35:00.000Z`, + `08:05:00.000Z`, + `08:35:00.000Z`, + `09:05:00.000Z`, + `09:35:00.000Z`, + `10:05:00.000Z`, + `10:35:00.000Z`, + `11:05:00.000Z`, + `11:35:00.000Z`, + `12:05:00.000Z`, + ], + { + dateString: plus2DateString, + } + ); + }); + test("Check for Date overrides", async () => { const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); const { dateString: plus2DateString } = getDate({ dateIncrement: 2 }); @@ -1132,6 +1195,7 @@ function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) { seatsPerTimeSlot: null, metadata: {}, minimumBookingNotice: 0, + offsetStart: 0, }; const foundEvents: Record = {}; const eventTypesWithUsers = eventTypes.map((eventType) => { diff --git a/apps/web/test/lib/slots.test.ts b/apps/web/test/lib/slots.test.ts index b2e4349eff..4bc5f2ebc1 100644 --- a/apps/web/test/lib/slots.test.ts +++ b/apps/web/test/lib/slots.test.ts @@ -24,6 +24,7 @@ describe("Tests the slot logic", () => { }, ], eventLength: 60, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(24); @@ -46,6 +47,7 @@ describe("Tests the slot logic", () => { }, ], eventLength: 60, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(12); @@ -66,6 +68,7 @@ describe("Tests the slot logic", () => { }, ], eventLength: 60, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(0); @@ -87,6 +90,7 @@ describe("Tests the slot logic", () => { minimumBookingNotice: 0, workingHours, eventLength: 60, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(0); @@ -108,6 +112,7 @@ describe("Tests the slot logic", () => { }, ], eventLength: 60, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(11); @@ -129,8 +134,30 @@ describe("Tests the slot logic", () => { }, ], eventLength: 20, + offsetStart: 0, organizerTimeZone: "America/Toronto", }) ).toHaveLength(71); }); + + it("can fit 48 25 minute slots with a 5 minute offset for an empty day", async () => { + expect( + getSlots({ + inviteeDate: dayjs.utc().add(1, "day"), + frequency: 25, + minimumBookingNotice: 0, + workingHours: [ + { + userId: 1, + days: Array.from(Array(7).keys()), + startTime: MINUTES_DAY_START, + endTime: MINUTES_DAY_END, + }, + ], + eventLength: 25, + offsetStart: 5, + organizerTimeZone: "America/Toronto", + }) + ).toHaveLength(48); + }); }); diff --git a/packages/features/ee/teams/components/TeamAvailabilityTimes.tsx b/packages/features/ee/teams/components/TeamAvailabilityTimes.tsx index e46e555ec7..a2c492546e 100644 --- a/packages/features/ee/teams/components/TeamAvailabilityTimes.tsx +++ b/packages/features/ee/teams/components/TeamAvailabilityTimes.tsx @@ -41,6 +41,7 @@ export default function TeamAvailabilityTimes(props: Props) { inviteeDate: props.selectedDate, workingHours: data?.workingHours || [], minimumBookingNotice: 0, + offsetStart: 0, eventLength: props.frequency, organizerTimeZone: `${data?.timeZone}`, }) diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 8a069fee1c..00c916f9a1 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -63,6 +63,7 @@ const commons = { periodType: PeriodType.UNLIMITED, periodDays: null, slotInterval: null, + offsetStart: 0, locations: [{ type: DailyLocationType }], customInputs, disableGuests: true, diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 1b74510789..4a8ad52e27 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -78,6 +78,7 @@ export default async function getEventTypeById({ slug: true, description: true, length: true, + offsetStart: true, hidden: true, locations: true, eventName: true, diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index 4d65d228af..1bf034229e 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -12,17 +12,20 @@ export type GetSlots = { dateOverrides?: DateOverride[]; minimumBookingNotice: number; eventLength: number; + offsetStart: number; organizerTimeZone: string; }; export type TimeFrame = { userIds?: number[]; startTime: number; endTime: number }; const minimumOfOne = (input: number) => (input < 1 ? 1 : input); +const minimumOfZero = (input: number) => (input < 0 ? 0 : input); function buildSlots({ startOfInviteeDay, computedLocalAvailability, frequency, eventLength, + offsetStart, startDate, organizerTimeZone, inviteeTimeZone, @@ -32,6 +35,7 @@ function buildSlots({ startDate: Dayjs; frequency: number; eventLength: number; + offsetStart: number; organizerTimeZone: string; inviteeTimeZone: string; }) { @@ -42,6 +46,8 @@ function buildSlots({ // keep the old safeguards in; may be needed. frequency = minimumOfOne(frequency); eventLength = minimumOfOne(eventLength); + offsetStart = minimumOfZero(offsetStart); + // A day starts at 00:00 unless the startDate is the same as the current day const dayStart = startOfInviteeDay.isSame(startDate, "day") ? Math.ceil((startDate.hour() * 60 + startDate.minute()) / frequency) * frequency @@ -84,7 +90,11 @@ function buildSlots({ for (const [boundaryStart, boundaryEnd] of ranges) { // loop through the day, based on frequency. - for (let slotStart = boundaryStart; slotStart < boundaryEnd; slotStart += frequency) { + for ( + let slotStart = boundaryStart + offsetStart; + slotStart < boundaryEnd; + slotStart += offsetStart + frequency + ) { computedLocalAvailability.forEach((item) => { // TODO: This logic does not allow for past-midnight bookings. if (slotStart < item.startTime || slotStart > item.endTime + 1 - eventLength) { @@ -139,6 +149,7 @@ const getSlots = ({ workingHours, dateOverrides = [], eventLength, + offsetStart, organizerTimeZone, }: GetSlots) => { // current date in invitee tz @@ -240,6 +251,7 @@ const getSlots = ({ startDate, frequency, eventLength, + offsetStart, organizerTimeZone, inviteeTimeZone: timeZone, }); diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index c6f116cff4..51656d7d0c 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -71,6 +71,7 @@ export const buildEventType = (eventType?: Partial): EventType => { position: 1, locations: null, length: 15, + offsetStart: 0, hidden: false, userId: null, teamId: null, diff --git a/packages/prisma/migrations/20230424163718_offset_start_times/migration.sql b/packages/prisma/migrations/20230424163718_offset_start_times/migration.sql new file mode 100644 index 0000000000..f962051da4 --- /dev/null +++ b/packages/prisma/migrations/20230424163718_offset_start_times/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "offsetStart" INTEGER NOT NULL DEFAULT 0; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f55bf2e8a2..d000df881a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -57,6 +57,7 @@ model EventType { /// @zod.custom(imports.eventTypeLocations) locations Json? length Int + offsetStart Int @default(0) hidden Boolean @default(false) hosts Host[] users User[] @relation("user_eventtype") diff --git a/packages/prisma/selects/event-types.ts b/packages/prisma/selects/event-types.ts index 3c873ba5f4..2908cb621e 100644 --- a/packages/prisma/selects/event-types.ts +++ b/packages/prisma/selects/event-types.ts @@ -65,6 +65,7 @@ export const availiblityPageEventTypeSelect = Prisma.validator { // eslint-disable-next-line teamId, bookingFields, + offsetStart, ...rest } = input; @@ -102,6 +103,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { data.durationLimits = durationLimits; } + if (offsetStart !== undefined) { + if (offsetStart < 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Offset start time must be zero or greater." }); + } + data.offsetStart = offsetStart; + } + if (schedule) { // Check that the schedule belongs to the user const userScheduleQuery = await ctx.prisma.schedule.findFirst({ diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index d47ece5cef..d699f4f84c 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -80,6 +80,7 @@ export async function getEventType(input: TGetScheduleInputSchema) { slug: true, minimumBookingNotice: true, length: true, + offsetStart: true, seatsPerTimeSlot: true, timeZone: true, slotInterval: true, @@ -281,6 +282,7 @@ export async function getSchedule(input: TGetScheduleInputSchema) { workingHours, dateOverrides, minimumBookingNotice: eventType.minimumBookingNotice, + offsetStart: eventType.offsetStart, frequency: eventType.slotInterval || input.duration || eventType.length, organizerTimeZone: eventType.timeZone || eventType?.schedule?.timeZone || userAvailability?.[0]?.timeZone, From cfd1a72c642e4130061c1c6903b58270c9d9439d Mon Sep 17 00:00:00 2001 From: "GitStart-Cal.com" <121884634+gitstart-calcom@users.noreply.github.com> Date: Wed, 17 May 2023 12:47:44 +0000 Subject: [PATCH 02/42] [CAL-1545] replace heroicons with lucide.dev (#8599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix linting * [CAL-1545] replace heroicons with lucide.dev * [CAL-1545] replace heroicons with lucide.dev * [CAL-1545] replace heroicons with lucide.dev --------- Co-authored-by: gitstart-calcom Co-authored-by: Efraín Rochín Co-authored-by: Peer Richelsen Co-authored-by: Keith Williams --- .../components/booking/BookingListItem.tsx | 4 +-- .../steps-views/ConnectCalendars.tsx | 5 ++-- .../steps-views/ConnectedVideoStep.tsx | 5 ++-- .../steps-views/SetupAvailability.tsx | 4 +-- .../steps-views/UserProfile.tsx | 4 +-- .../steps-views/UserSettings.tsx | 4 +-- .../UsernameAvailability/PremiumTextfield.tsx | 12 ++++++--- apps/web/next.config.js | 6 ----- apps/web/package.json | 1 - apps/web/pages/[user].tsx | 7 ++--- apps/web/pages/auth/verify.tsx | 8 +++--- package.json | 2 +- .../giphy/components/SearchDialog.tsx | 6 ++--- .../giphy/components/SelectGifInput.tsx | 8 +++--- .../components/confirmDialog.tsx | 4 +-- packages/features/calendars/DatePicker.tsx | 7 +++-- .../teams/components/TeamInviteListItem.tsx | 4 +-- packages/ui/components/alert/Alert.tsx | 26 ++++++++++++------- .../ui/components/form/dropdown/Dropdown.tsx | 6 ++--- .../ui/components/top-banner/TopBanner.tsx | 5 ++-- packages/ui/form/radio-area/Select.tsx | 4 +-- yarn.lock | 11 -------- 22 files changed, 67 insertions(+), 76 deletions(-) diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 7af2db8944..3dc01a2dcb 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -29,7 +29,7 @@ import { TableActions, TextAreaField, } from "@calcom/ui"; -import { Check, Clock, MapPin, RefreshCcw, Send, Slash, X, CreditCard } from "@calcom/ui/components/icon"; +import { Check, Clock, MapPin, RefreshCcw, Send, Ban, X, CreditCard } from "@calcom/ui/components/icon"; import useMeQuery from "@lib/hooks/useMeQuery"; @@ -119,7 +119,7 @@ function BookingListItem(booking: BookingItemProps) { onClick: () => { setRejectionDialogIsOpen(true); }, - icon: Slash, + icon: Ban, disabled: mutation.isLoading, }, // For bookings with payment, only confirm if the booking is paid for diff --git a/apps/web/components/getting-started/steps-views/ConnectCalendars.tsx b/apps/web/components/getting-started/steps-views/ConnectCalendars.tsx index d2e91f7c09..2109f01a4c 100644 --- a/apps/web/components/getting-started/steps-views/ConnectCalendars.tsx +++ b/apps/web/components/getting-started/steps-views/ConnectCalendars.tsx @@ -1,9 +1,8 @@ -import { ArrowRightIcon } from "@heroicons/react/solid"; - import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { List } from "@calcom/ui"; +import { ArrowRight } from "@calcom/ui/components/icon"; import { AppConnectionItem } from "../components/AppConnectionItem"; import { ConnectedCalendarItem } from "../components/ConnectedCalendarItem"; @@ -85,7 +84,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => { onClick={() => nextStep()} disabled={disabledNextButton}> {firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`} -
diff --git a/apps/web/components/getting-started/steps-views/UserProfile.tsx b/apps/web/components/getting-started/steps-views/UserProfile.tsx index 1657da0044..3867c43adb 100644 --- a/apps/web/components/getting-started/steps-views/UserProfile.tsx +++ b/apps/web/components/getting-started/steps-views/UserProfile.tsx @@ -1,4 +1,3 @@ -import { ArrowRightIcon } from "@heroicons/react/solid"; import { useRouter } from "next/router"; import type { FormEvent } from "react"; import { useRef, useState } from "react"; @@ -11,6 +10,7 @@ import turndown from "@calcom/lib/turndownService"; import { trpc } from "@calcom/trpc/react"; import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui"; import { Avatar } from "@calcom/ui"; +import { ArrowRight } from "@calcom/ui/components/icon"; import type { IOnboardingPageProps } from "../../../pages/getting-started/[[...step]]"; @@ -159,7 +159,7 @@ const UserProfile = (props: IUserProfileProps) => { type="submit" className="text-inverted mt-8 flex w-full flex-row justify-center rounded-md border border-black bg-black p-2 text-center text-sm"> {t("finish")} -