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:
parent
b8b6c48d7d
commit
23b3a6661c
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`,
|
||||
})
|
||||
|
|
|
@ -63,6 +63,7 @@ const commons = {
|
|||
periodType: PeriodType.UNLIMITED,
|
||||
periodDays: null,
|
||||
slotInterval: null,
|
||||
offsetStart: 0,
|
||||
locations: [{ type: DailyLocationType }],
|
||||
customInputs,
|
||||
disableGuests: true,
|
||||
|
|
|
@ -78,6 +78,7 @@ export default async function getEventTypeById({
|
|||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
offsetStart: true,
|
||||
hidden: true,
|
||||
locations: true,
|
||||
eventName: true,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "offsetStart" INTEGER NOT NULL DEFAULT 0;
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue
Block a user