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 <peeroke@gmail.com>
This commit is contained in:
Rob Jackson 2023-05-17 12:56:55 +01:00 committed by GitHub
parent b8b6c48d7d
commit 23b3a6661c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 174 additions and 6 deletions

View File

@ -108,7 +108,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
});
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
const { t } = useLocale();
const { t, i18n } = useLocale();
const formMethods = useFormContext<FormValues>();
const PERIOD_TYPES = [
@ -149,12 +149,25 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
];
// offsetStart toggle is client-side only, opened by default if offsetStart is set
const offsetStartValue = useWatch({
control: formMethods.control,
name: "offsetStart",
});
const [offsetToggle, setOffsetToggle] = useState(() => 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 (
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
@ -432,6 +445,32 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</SettingsToggle>
)}
/>
<hr className="border-subtle" />
<SettingsToggle
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
if (!active) {
formMethods.setValue("offsetStart", 0);
}
}}>
<TextField
required
type="number"
{...offsetStartLockedProps}
label={t("offset_start")}
defaultValue={eventType.offsetStart}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}
hint={t("offset_start_description", {
originalTime: offsetOriginalTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
adjustedTime: offsetAdjustedTime.toLocaleTimeString(i18n.language, { timeStyle: "short" }),
})}
/>
</SettingsToggle>
</div>
);
};

View File

@ -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.

View File

@ -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?",

View File

@ -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<number, boolean> = {};
const eventTypesWithUsers = eventTypes.map((eventType) => {

View File

@ -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);
});
});

View File

@ -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}`,
})

View File

@ -63,6 +63,7 @@ const commons = {
periodType: PeriodType.UNLIMITED,
periodDays: null,
slotInterval: null,
offsetStart: 0,
locations: [{ type: DailyLocationType }],
customInputs,
disableGuests: true,

View File

@ -78,6 +78,7 @@ export default async function getEventTypeById({
slug: true,
description: true,
length: true,
offsetStart: true,
hidden: true,
locations: true,
eventName: true,

View File

@ -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,
});

View File

@ -71,6 +71,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
position: 1,
locations: null,
length: 15,
offsetStart: 0,
hidden: false,
userId: null,
teamId: null,

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "offsetStart" INTEGER NOT NULL DEFAULT 0;

View File

@ -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")

View File

@ -65,6 +65,7 @@ export const availiblityPageEventTypeSelect = Prisma.validator<Prisma.EventTypeS
availability: true,
description: true,
length: true,
offsetStart: true,
price: true,
currency: true,
periodType: true,

View File

@ -501,6 +501,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
price: true,
slug: true,
length: true,
offsetStart: true,
locations: true,
hidden: true,
availability: true,

View File

@ -45,6 +45,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// 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({

View File

@ -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,