diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000..e49a7e6569 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["next/babel"] +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 182300ba52..6b0d79e8ac 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -24,7 +24,8 @@ "env": { "browser": true, "node": true, - "es6": true + "es6": true, + "jest": true }, "settings": { "react": { diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 6f4e413033..bfa4a0cfd3 100644 --- a/components/booking/AvailableTimes.tsx +++ b/components/booking/AvailableTimes.tsx @@ -1,112 +1,40 @@ -import dayjs from "dayjs"; -import isBetween from "dayjs/plugin/isBetween"; -dayjs.extend(isBetween); -import { useEffect, useState, useMemo } from "react"; -import getSlots from "../../lib/slots"; import Link from "next/link"; -import { timeZone } from "../../lib/clock"; import { useRouter } from "next/router"; +import Slots from "./Slots"; import { ExclamationIcon } from "@heroicons/react/solid"; -const AvailableTimes = (props) => { +const AvailableTimes = ({ date, eventLength, eventTypeId, workingHours, timeFormat, user }) => { const router = useRouter(); - const { user, rescheduleUid } = router.query; - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(false); - - const times = useMemo(() => { - const slots = getSlots({ - calendarTimeZone: props.user.timeZone, - selectedTimeZone: timeZone(), - eventLength: props.eventType.length, - selectedDate: props.date, - dayStartTime: props.user.startTime, - dayEndTime: props.user.endTime, - }); - - return slots; - }, [props.date]); - - const handleAvailableSlots = (busyTimes: []) => { - // Check for conflicts - for (let i = times.length - 1; i >= 0; i -= 1) { - busyTimes.forEach((busyTime) => { - const startTime = dayjs(busyTime.start); - const endTime = dayjs(busyTime.end); - - // Check if start times are the same - if (dayjs(times[i]).format("HH:mm") == startTime.format("HH:mm")) { - times.splice(i, 1); - } - - // Check if time is between start and end times - if (dayjs(times[i]).isBetween(startTime, endTime)) { - times.splice(i, 1); - } - - // Check if slot end time is between start and end time - if (dayjs(times[i]).add(props.eventType.length, "minutes").isBetween(startTime, endTime)) { - times.splice(i, 1); - } - - // Check if startTime is between slot - if (startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, "minutes"))) { - times.splice(i, 1); - } - }); - } - // Display available times - setLoaded(true); - }; - - // Re-render only when invitee changes date - useEffect(() => { - setLoaded(false); - setError(false); - fetch( - `/api/availability/${user}?dateFrom=${props.date.startOf("day").utc().format()}&dateTo=${props.date - .endOf("day") - .utc() - .format()}` - ) - .then((res) => res.json()) - .then(handleAvailableSlots) - .catch((e) => { - console.error(e); - setError(true); - }); - }, [props.date]); - + const { rescheduleUid } = router.query; + const { slots, isFullyBooked, hasErrors } = Slots({ date, eventLength, workingHours }); return (
- {props.date.format("dddd DD MMMM YYYY")} + {date.format("dddd DD MMMM YYYY")}
- {!error && - loaded && - times.length > 0 && - times.map((time) => ( -
+ {slots.length > 0 && + slots.map((slot) => ( +
- - {dayjs(time).tz(timeZone()).format(props.timeFormat)} + + {slot.format(timeFormat)}
))} - {!error && loaded && times.length == 0 && ( + {isFullyBooked && (
-

{props.user.name} is all booked today.

+

{user.name} is all booked today.

)} - {!error && !loaded &&
} - {error && ( + + {!isFullyBooked && slots.length === 0 && !hasErrors &&
} + + {hasErrors && (
@@ -116,9 +44,9 @@ const AvailableTimes = (props) => {

Could not load the available time slots.{" "} - Contact {props.user.name} via e-mail + Contact {user.name} via e-mail

diff --git a/components/booking/DatePicker.tsx b/components/booking/DatePicker.tsx new file mode 100644 index 0000000000..1bea6caf65 --- /dev/null +++ b/components/booking/DatePicker.tsx @@ -0,0 +1,134 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid"; +import { useEffect, useState } from "react"; +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import getSlots from "@lib/slots"; +dayjs.extend(utc); +dayjs.extend(timezone); + +const DatePicker = ({ + weekStart, + onDatePicked, + workingHours, + organizerTimeZone, + inviteeTimeZone, + eventLength, +}) => { + const [calendar, setCalendar] = useState([]); + const [selectedMonth, setSelectedMonth]: number = useState(); + const [selectedDate, setSelectedDate]: Dayjs = useState(); + + useEffect(() => { + setSelectedMonth(dayjs().tz(inviteeTimeZone).month()); + }, []); + + useEffect(() => { + if (selectedDate) onDatePicked(selectedDate); + }, [selectedDate]); + + // Handle month changes + const incrementMonth = () => { + setSelectedMonth(selectedMonth + 1); + }; + + const decrementMonth = () => { + setSelectedMonth(selectedMonth - 1); + }; + + useEffect(() => { + if (!selectedMonth) { + // wish next had a way of dealing with this magically; + return; + } + + const inviteeDate = dayjs().tz(inviteeTimeZone).month(selectedMonth); + + const isDisabled = (day: number) => { + const date: Dayjs = inviteeDate.date(day); + return ( + date.endOf("day").isBefore(dayjs().tz(inviteeTimeZone)) || + !getSlots({ + inviteeDate: date, + frequency: eventLength, + workingHours, + organizerTimeZone, + }).length + ); + }; + + // Set up calendar + const daysInMonth = inviteeDate.daysInMonth(); + const days = []; + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } + + // Create placeholder elements for empty days in first week + let weekdayOfFirst = inviteeDate.date(1).day(); + if (weekStart === "Monday") { + weekdayOfFirst -= 1; + if (weekdayOfFirst < 0) weekdayOfFirst = 6; + } + const emptyDays = Array(weekdayOfFirst) + .fill(null) + .map((day, i) => ( +
+ {null} +
+ )); + + // Combine placeholder days with actual days + setCalendar([ + ...emptyDays, + ...days.map((day) => ( + + )), + ]); + }, [selectedMonth, inviteeTimeZone, selectedDate]); + + return selectedMonth ? ( +
+
+ {dayjs().month(selectedMonth).format("MMMM YYYY")} +
+ + +
+
+
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + .sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0)) + .map((weekDay) => ( +
+ {weekDay} +
+ ))} + {calendar} +
+
+ ) : null; +}; + +export default DatePicker; diff --git a/components/booking/Slots.tsx b/components/booking/Slots.tsx new file mode 100644 index 0000000000..8f92aaeb14 --- /dev/null +++ b/components/booking/Slots.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import getSlots from "../../lib/slots"; +import dayjs, { Dayjs } from "dayjs"; +import isBetween from "dayjs/plugin/isBetween"; +import utc from "dayjs/plugin/utc"; +dayjs.extend(isBetween); +dayjs.extend(utc); + +type Props = { + eventLength: number; + minimumBookingNotice?: number; + date: Dayjs; +}; + +const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerUtcOffset }: Props) => { + minimumBookingNotice = minimumBookingNotice || 0; + + const router = useRouter(); + const { user } = router.query; + const [slots, setSlots] = useState([]); + const [isFullyBooked, setIsFullyBooked] = useState(false); + const [hasErrors, setHasErrors] = useState(false); + + useEffect(() => { + setSlots([]); + setIsFullyBooked(false); + setHasErrors(false); + fetch( + `/api/availability/${user}?dateFrom=${date.startOf("day").utc().startOf("day").format()}&dateTo=${date + .endOf("day") + .utc() + .endOf("day") + .format()}` + ) + .then((res) => res.json()) + .then(handleAvailableSlots) + .catch((e) => { + console.error(e); + setHasErrors(true); + }); + }, [date]); + + const handleAvailableSlots = (busyTimes: []) => { + const times = getSlots({ + frequency: eventLength, + inviteeDate: date, + workingHours, + minimumBookingNotice, + organizerUtcOffset, + }); + + const timesLengthBeforeConflicts: number = times.length; + + // Check for conflicts + for (let i = times.length - 1; i >= 0; i -= 1) { + busyTimes.every((busyTime): boolean => { + const startTime = dayjs(busyTime.start).utc(); + const endTime = dayjs(busyTime.end).utc(); + // Check if start times are the same + if (times[i].utc().isSame(startTime)) { + times.splice(i, 1); + } + // Check if time is between start and end times + else if (times[i].utc().isBetween(startTime, endTime)) { + times.splice(i, 1); + } + // Check if slot end time is between start and end time + else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) { + times.splice(i, 1); + } + // Check if startTime is between slot + else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) { + times.splice(i, 1); + } else { + return true; + } + return false; + }); + } + + if (times.length === 0 && timesLengthBeforeConflicts !== 0) { + setIsFullyBooked(true); + } + // Display available times + setSlots(times); + }; + + return { + slots, + isFullyBooked, + hasErrors, + }; +}; + +export default Slots; diff --git a/components/booking/TimeOptions.tsx b/components/booking/TimeOptions.tsx index 38aafd8bf5..580e174e4b 100644 --- a/components/booking/TimeOptions.tsx +++ b/components/booking/TimeOptions.tsx @@ -1,73 +1,72 @@ -import {Switch} from "@headlessui/react"; +import { Switch } from "@headlessui/react"; import TimezoneSelect from "react-timezone-select"; -import {useEffect, useState} from "react"; -import {timeZone, is24h} from '../../lib/clock'; +import { useEffect, useState } from "react"; +import { timeZone, is24h } from "../../lib/clock"; function classNames(...classes) { - return classes.filter(Boolean).join(' ') + return classes.filter(Boolean).join(" "); } const TimeOptions = (props) => { - - const [selectedTimeZone, setSelectedTimeZone] = useState(''); + const [selectedTimeZone, setSelectedTimeZone] = useState(""); const [is24hClock, setIs24hClock] = useState(false); - useEffect( () => { + useEffect(() => { setIs24hClock(is24h()); setSelectedTimeZone(timeZone()); }, []); - useEffect( () => { - props.onSelectTimeZone(timeZone(selectedTimeZone)); + useEffect(() => { + if (selectedTimeZone && timeZone() && selectedTimeZone !== timeZone()) { + props.onSelectTimeZone(timeZone(selectedTimeZone)); + } }, [selectedTimeZone]); - useEffect( () => { + useEffect(() => { props.onToggle24hClock(is24h(is24hClock)); }, [is24hClock]); - return selectedTimeZone !== "" && ( -
-
-
Time Options
-
- - - am/pm - - - Use setting -
- setSelectedTimeZone(tz.value)} - className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" - /> -
+ ) ); -} +}; -export default TimeOptions; \ No newline at end of file +export default TimeOptions; diff --git a/components/ui/PoweredByCalendso.tsx b/components/ui/PoweredByCalendso.tsx new file mode 100644 index 0000000000..2022fa931e --- /dev/null +++ b/components/ui/PoweredByCalendso.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; + +const PoweredByCalendso = (props) => ( + +); + +export default PoweredByCalendso; \ No newline at end of file diff --git a/components/ui/Scheduler.tsx b/components/ui/Scheduler.tsx new file mode 100644 index 0000000000..045c726d20 --- /dev/null +++ b/components/ui/Scheduler.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useState } from "react"; +import TimezoneSelect from "react-timezone-select"; +import { TrashIcon } from "@heroicons/react/outline"; +import { WeekdaySelect } from "./WeekdaySelect"; +import SetTimesModal from "./modal/SetTimesModal"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import { Availability } from "@prisma/client"; +dayjs.extend(utc); +dayjs.extend(timezone); + +type Props = { + timeZone: string; + availability: Availability[]; + setTimeZone: unknown; +}; + +export const Scheduler = ({ + availability, + setAvailability, + timeZone: selectedTimeZone, + setTimeZone, +}: Props) => { + const [editSchedule, setEditSchedule] = useState(-1); + const [dateOverrides, setDateOverrides] = useState([]); + const [openingHours, setOpeningHours] = useState([]); + + useEffect(() => { + setOpeningHours( + availability + .filter((item: Availability) => item.days.length !== 0) + .map((item) => { + item.startDate = dayjs().utc().startOf("day").add(item.startTime, "minutes"); + item.endDate = dayjs().utc().startOf("day").add(item.endTime, "minutes"); + return item; + }) + ); + setDateOverrides(availability.filter((item: Availability) => item.date)); + }, []); + + // updates availability to how it should be formatted outside this component. + useEffect(() => { + setAvailability({ + dateOverrides: dateOverrides, + openingHours: openingHours, + }); + }, [dateOverrides, openingHours]); + + const addNewSchedule = () => setEditSchedule(openingHours.length); + + const applyEditSchedule = (changed) => { + // new entry + if (!changed.days) { + changed.days = [1, 2, 3, 4, 5]; // Mon - Fri + setOpeningHours(openingHours.concat(changed)); + } else { + // update + const replaceWith = { ...openingHours[editSchedule], ...changed }; + openingHours.splice(editSchedule, 1, replaceWith); + setOpeningHours([].concat(openingHours)); + } + }; + + const removeScheduleAt = (toRemove: number) => { + openingHours.splice(toRemove, 1); + setOpeningHours([].concat(openingHours)); + }; + + const OpeningHours = ({ idx, item }) => ( +
  • +
    + (item.days = selected)} /> + +
    + +
  • + ); + + return ( +
    +
    +
    +
    + +
    + setTimeZone(tz.value)} + className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + /> +
    +
    +
      + {openingHours.map((item, idx) => ( + + ))} +
    +
    + +
    +
    + {/*

    Add date overrides

    +

    + Add dates when your availability changes from your weekly hours +

    + */} +
    +
    + {editSchedule >= 0 && ( + applyEditSchedule({ ...(openingHours[editSchedule] || {}), ...times })} + onExit={() => setEditSchedule(-1)} + /> + )} + {/*{showDateOverrideModal && + + }*/} +
    + ); +}; diff --git a/components/ui/WeekdaySelect.tsx b/components/ui/WeekdaySelect.tsx new file mode 100644 index 0000000000..a9f371d827 --- /dev/null +++ b/components/ui/WeekdaySelect.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; + +export const WeekdaySelect = (props) => { + const [activeDays, setActiveDays] = useState( + [...Array(7).keys()].map((v, i) => (props.defaultValue || []).includes(i)) + ); + + const days = ["S", "M", "T", "W", "T", "F", "S"]; + + useEffect(() => { + props.onSelect(activeDays.map((v, idx) => (v ? idx : -1)).filter((v) => v !== -1)); + }, [activeDays]); + + const toggleDay = (e, idx: number) => { + e.preventDefault(); + activeDays[idx] = !activeDays[idx]; + setActiveDays([].concat(activeDays)); + }; + + return ( +
    +
    + {days.map((day, idx) => + activeDays[idx] ? ( + + ) : ( + + ) + )} +
    +
    + ); +}; diff --git a/components/ui/modal/SetTimesModal.tsx b/components/ui/modal/SetTimesModal.tsx new file mode 100644 index 0000000000..2334802e11 --- /dev/null +++ b/components/ui/modal/SetTimesModal.tsx @@ -0,0 +1,146 @@ +import { ClockIcon } from "@heroicons/react/outline"; +import { useRef } from "react"; + +export default function SetTimesModal(props) { + const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60]; + const [endHours, endMinutes] = [Math.floor(props.endTime / 60), props.endTime % 60]; + + const startHoursRef = useRef(); + const startMinsRef = useRef(); + const endHoursRef = useRef(); + const endMinsRef = useRef(); + + function updateStartEndTimesHandler(event) { + event.preventDefault(); + + const enteredStartHours = parseInt(startHoursRef.current.value); + const enteredStartMins = parseInt(startMinsRef.current.value); + const enteredEndHours = parseInt(endHoursRef.current.value); + const enteredEndMins = parseInt(endMinsRef.current.value); + + props.onChange({ + startTime: enteredStartHours * 60 + enteredStartMins, + endTime: enteredEndHours * 60 + enteredEndMins, + }); + + props.onExit(0); + } + + return ( +
    +
    + + + + +
    +
    +
    + +
    +
    + +
    +

    Set your work schedule

    +
    +
    +
    +
    + +
    + + +
    + : +
    + + +
    +
    +
    + +
    + + +
    + : +
    + + +
    +
    +
    + + +
    +
    +
    +
    + ); +} diff --git a/lib/jsonUtils.ts b/lib/jsonUtils.ts new file mode 100644 index 0000000000..3f617cb0ce --- /dev/null +++ b/lib/jsonUtils.ts @@ -0,0 +1,11 @@ +export const validJson = (jsonString: string) => { + try { + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; + } + } catch (e) { + console.log("Invalid JSON:", e); + } + return false; +}; diff --git a/lib/slots.ts b/lib/slots.ts index 157b56a692..5beeb9f8af 100644 --- a/lib/slots.ts +++ b/lib/slots.ts @@ -1,94 +1,134 @@ -const dayjs = require("dayjs"); - -const isToday = require("dayjs/plugin/isToday"); -const utc = require("dayjs/plugin/utc"); -const timezone = require("dayjs/plugin/timezone"); - -dayjs.extend(isToday); +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -const getMinutesFromMidnight = (date) => { - return date.hour() * 60 + date.minute(); +type WorkingHour = { + days: number[]; + startTime: number; + endTime: number; }; -const getSlots = ({ - calendarTimeZone, - eventLength, - selectedTimeZone, - selectedDate, - dayStartTime, - dayEndTime -}) => { +type GetSlots = { + inviteeDate: Dayjs; + frequency: number; + workingHours: WorkingHour[]; + minimumBookingNotice?: number; + organizerTimeZone: string; +}; - if(!selectedDate) return [] - - const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day"); +type Boundary = { + lowerBound: number; + upperBound: number; +}; - // Simple case, same timezone - if (calendarTimeZone === selectedTimeZone) { - const slots = []; - const now = dayjs(); - for ( - let minutes = dayStartTime; - minutes <= dayEndTime - eventLength; - minutes += parseInt(eventLength, 10) - ) { - const slot = lowerBound.add(minutes, "minutes"); - if (slot > now) { - slots.push(slot); - } - } - return slots; +const freqApply = (cb, value: number, frequency: number): number => cb(value / frequency) * frequency; + +const intersectBoundary = (a: Boundary, b: Boundary) => { + if (a.upperBound < b.lowerBound || a.lowerBound > b.upperBound) { + return; } + return { + lowerBound: Math.max(b.lowerBound, a.lowerBound), + upperBound: Math.min(b.upperBound, a.upperBound), + }; +}; - const upperBound = selectedDate.tz(selectedTimeZone).endOf("day"); +// say invitee is -60,1380, and boundary is -120,240 - the overlap is -60,240 +const getOverlaps = (inviteeBoundary: Boundary, boundaries: Boundary[]) => + boundaries.map((boundary) => intersectBoundary(inviteeBoundary, boundary)).filter(Boolean); - // We need to start generating slots from the start of the calendarTimeZone day - const startDateTime = lowerBound - .tz(calendarTimeZone) +const organizerBoundaries = ( + workingHours: [], + inviteeDate: Dayjs, + inviteeBounds: Boundary, + organizerTimeZone +): Boundary[] => { + const boundaries: Boundary[] = []; + + const startDay: number = +inviteeDate + .utc() .startOf("day") - .add(dayStartTime, "minutes"); + .add(inviteeBounds.lowerBound, "minutes") + .format("d"); + const endDay: number = +inviteeDate + .utc() + .startOf("day") + .add(inviteeBounds.upperBound, "minutes") + .format("d"); - let phase = 0; - if (startDateTime < lowerBound) { - // Getting minutes of the first event in the day of the chooser - const diff = lowerBound.diff(startDateTime, "minutes"); - - // finding first event - phase = diff + eventLength - (diff % eventLength); - } - - // We can stop as soon as the selectedTimeZone day ends - const endDateTime = upperBound - .tz(calendarTimeZone) - .subtract(eventLength, "minutes"); - - const maxMinutes = endDateTime.diff(startDateTime, "minutes"); - - const slots = []; - const now = dayjs(); - for ( - let minutes = phase; - minutes <= maxMinutes; - minutes += parseInt(eventLength, 10) - ) { - const slot = startDateTime.add(minutes, "minutes"); - - const minutesFromMidnight = getMinutesFromMidnight(slot); - - if ( - minutesFromMidnight < dayStartTime || - minutesFromMidnight > dayEndTime - eventLength || - slot < now - ) { - continue; + workingHours.forEach((item) => { + const lowerBound: number = item.startTime - dayjs().tz(organizerTimeZone).utcOffset(); + const upperBound: number = item.endTime - dayjs().tz(organizerTimeZone).utcOffset(); + if (startDay !== endDay) { + if (inviteeBounds.lowerBound < 0) { + // lowerBound edges into the previous day + if (item.days.includes(startDay)) { + boundaries.push({ lowerBound: lowerBound - 1440, upperBound: upperBound - 1440 }); + } + if (item.days.includes(endDay)) { + boundaries.push({ lowerBound, upperBound }); + } + } else { + // upperBound edges into the next day + if (item.days.includes(endDay)) { + boundaries.push({ lowerBound: lowerBound + 1440, upperBound: upperBound + 1440 }); + } + if (item.days.includes(startDay)) { + boundaries.push({ lowerBound, upperBound }); + } + } + } else { + boundaries.push({ lowerBound, upperBound }); } + }); + return boundaries; +}; - slots.push(slot.tz(selectedTimeZone)); +const inviteeBoundary = (startTime: number, utcOffset: number, frequency: number): Boundary => { + const upperBound: number = freqApply(Math.floor, 1440 - utcOffset, frequency); + const lowerBound: number = freqApply(Math.ceil, startTime - utcOffset, frequency); + return { + lowerBound, + upperBound, + }; +}; + +const getSlotsBetweenBoundary = (frequency: number, { lowerBound, upperBound }: Boundary) => { + const slots: Dayjs[] = []; + for (let minutes = 0; lowerBound + minutes <= upperBound - frequency; minutes += frequency) { + slots.push( + dayjs + .utc() + .startOf("day") + .add(lowerBound + minutes, "minutes") + ); } - return slots; }; -export default getSlots +const getSlots = ({ + inviteeDate, + frequency, + minimumBookingNotice, + workingHours, + organizerTimeZone, +}: GetSlots): Dayjs[] => { + const startTime = dayjs.utc().isSame(dayjs(inviteeDate), "day") + ? inviteeDate.hour() * 60 + inviteeDate.minute() + (minimumBookingNotice || 0) + : 0; + + const inviteeBounds = inviteeBoundary(startTime, inviteeDate.utcOffset(), frequency); + + return getOverlaps( + inviteeBounds, + organizerBoundaries(workingHours, inviteeDate, inviteeBounds, organizerTimeZone) + ) + .reduce((slots, boundary: Boundary) => [...slots, ...getSlotsBetweenBoundary(frequency, boundary)], []) + .map((slot) => + slot.month(inviteeDate.month()).date(inviteeDate.date()).utcOffset(inviteeDate.utcOffset()) + ); +}; + +export default getSlots; diff --git a/package.json b/package.json index c59a83a0e4..b0ac1af372 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "test": "node --experimental-vm-modules node_modules/.bin/jest", "build": "next build", "start": "next start", "postinstall": "prisma generate", @@ -38,6 +39,7 @@ "uuid": "^8.3.2" }, "devDependencies": { + "@types/jest": "^26.0.23", "@types/node": "^14.14.33", "@types/nodemailer": "^6.4.2", "@types/react": "^17.0.3", @@ -50,7 +52,9 @@ "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "husky": "^6.0.0", + "jest": "^27.0.5", "lint-staged": "^11.0.0", + "mockdate": "^3.0.5", "postcss": "^8.2.8", "prettier": "^2.3.1", "prisma": "^2.23.0", @@ -62,5 +66,19 @@ "prettier --write", "eslint" ] + }, + "jest": { + "verbose": true, + "extensionsToTreatAsEsm": [ + ".ts" + ], + "moduleFileExtensions": [ + "js", + "ts" + ], + "moduleNameMapper": { + "^@components(.*)$": "/components$1", + "^@lib(.*)$": "/lib$1" + } } } diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index bf09b98776..fd81a9c516 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -1,115 +1,47 @@ import { useEffect, useState } from "react"; import { GetServerSideProps } from "next"; import Head from "next/head"; -import Link from "next/link"; +import { ClockIcon, GlobeIcon, ChevronDownIcon } from "@heroicons/react/solid"; import prisma from "../../lib/prisma"; import { useRouter } from "next/router"; -import dayjs, { Dayjs } from "dayjs"; -import { - ClockIcon, - GlobeIcon, - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, -} from "@heroicons/react/solid"; -import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(isSameOrBefore); -dayjs.extend(utc); -dayjs.extend(timezone); +import { Dayjs } from "dayjs"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import AvailableTimes from "../../components/booking/AvailableTimes"; import TimeOptions from "../../components/booking/TimeOptions"; import Avatar from "../../components/Avatar"; import { timeZone } from "../../lib/clock"; +import DatePicker from "../../components/booking/DatePicker"; +import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; export default function Type(props): Type { // Get router variables const router = useRouter(); const { rescheduleUid } = router.query; - // Initialise state const [selectedDate, setSelectedDate] = useState(); - const [selectedMonth, setSelectedMonth] = useState(dayjs().month()); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [timeFormat, setTimeFormat] = useState("h:mma"); const telemetry = useTelemetry(); - useEffect((): void => { + useEffect(() => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters())); }, [telemetry]); - // Handle month changes - const incrementMonth = () => { - setSelectedMonth(selectedMonth + 1); - }; - - const decrementMonth = () => { - setSelectedMonth(selectedMonth - 1); - }; - - // Set up calendar - const daysInMonth = dayjs().month(selectedMonth).daysInMonth(); - const days = []; - for (let i = 1; i <= daysInMonth; i++) { - days.push(i); - } - - // Create placeholder elements for empty days in first week - let weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); - if (props.user.weekStart === "Monday") { - weekdayOfFirst -= 1; - if (weekdayOfFirst < 0) weekdayOfFirst = 6; - } - const emptyDays = Array(weekdayOfFirst) - .fill(null) - .map((day, i) => ( -
    - {null} -
    - )); - - const changeDate = (day): void => { + const changeDate = (date: Dayjs) => { telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters())); - setSelectedDate(dayjs().month(selectedMonth).date(day)); + setSelectedDate(date); }; - // Combine placeholder days with actual days - const calendar = [ - ...emptyDays, - ...days.map((day) => ( - - )), - ]; - const handleSelectTimeZone = (selectedTimeZone: string): void => { if (selectedDate) { setSelectedDate(selectedDate.tz(selectedTimeZone)); } + setIsTimeOptionsOpen(false); }; - const handleToggle24hClock = (is24hClock: boolean): void => { - if (selectedDate) { - setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); - } + const handleToggle24hClock = (is24hClock: boolean) => { + setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); }; return ( @@ -190,63 +122,27 @@ export default function Type(props): Type { )}

    {props.eventType.description}

    -
    -
    - {dayjs().month(selectedMonth).format("MMMM YYYY")} -
    - - -
    -
    -
    - {props.user.weekStart !== "Monday" ? ( -
    Sun
    - ) : null} -
    Mon
    -
    Tue
    -
    Wed
    -
    Thu
    -
    Fri
    -
    Sat
    - {props.user.weekStart === "Monday" ? ( -
    Sun
    - ) : null} - {calendar} -
    -
    + {selectedDate && ( )}
    - {!props.user.hideBranding && ( -
    - - - powered by{" "} - Calendso Logo - - -
    - )} + {!props.user.hideBranding && }
    ); @@ -269,6 +165,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { timeZone: true, endTime: true, weekStart: true, + availability: true, hideBranding: true, }, }); @@ -291,6 +188,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => { title: true, description: true, length: true, + availability: true, + timeZone: true, }, }); @@ -300,10 +199,27 @@ export const getServerSideProps: GetServerSideProps = async (context) => { }; } + const getWorkingHours = (providesAvailability) => + providesAvailability.availability && providesAvailability.availability.length + ? providesAvailability.availability + : null; + + const workingHours: [] = + getWorkingHours(eventType) || + getWorkingHours(user) || + [ + { + days: [0, 1, 2, 3, 4, 5, 6], + startTime: user.startTime, + endTime: user.endTime, + }, + ].filter((availability): boolean => typeof availability["days"] !== "undefined"); + return { props: { user, eventType, + workingHours, }, }; }; diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts index e50fc4abee..dc5709e668 100644 --- a/pages/api/availability/eventtype.ts +++ b/pages/api/availability/eventtype.ts @@ -1,81 +1,111 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import {getSession} from 'next-auth/client'; -import prisma from '../../../lib/prisma'; +import type { NextApiRequest, NextApiResponse } from "next"; +import { getSession } from "next-auth/client"; +import prisma from "../../../lib/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const session = await getSession({req: req}); - if (!session) { - res.status(401).json({message: "Not authenticated"}); - return; - } + const session = await getSession({ req: req }); + if (!session) { + res.status(401).json({ message: "Not authenticated" }); + return; + } - if (req.method == "PATCH" || req.method == "POST") { - - const data = { - title: req.body.title, - slug: req.body.slug, - description: req.body.description, - length: parseInt(req.body.length), - hidden: req.body.hidden, - locations: req.body.locations, - eventName: req.body.eventName, - customInputs: !req.body.customInputs - ? undefined - : { - deleteMany: { - eventTypeId: req.body.id, - NOT: { - id: {in: req.body.customInputs.filter(input => !!input.id).map(e => e.id)} - } - }, - createMany: { - data: req.body.customInputs.filter(input => !input.id).map(input => ({ - type: input.type, - label: input.label, - required: input.required - })) - }, - update: req.body.customInputs.filter(input => !!input.id).map(input => ({ - data: { - type: input.type, - label: input.label, - required: input.required - }, - where: { - id: input.id - } - })) + if (req.method == "PATCH" || req.method == "POST") { + const data = { + title: req.body.title, + slug: req.body.slug, + description: req.body.description, + length: parseInt(req.body.length), + hidden: req.body.hidden, + locations: req.body.locations, + eventName: req.body.eventName, + customInputs: !req.body.customInputs + ? undefined + : { + deleteMany: { + eventTypeId: req.body.id, + NOT: { + id: { in: req.body.customInputs.filter((input) => !!input.id).map((e) => e.id) }, }, - }; - - if (req.method == "POST") { - const createEventType = await prisma.eventType.create({ - data: { - userId: session.user.id, - ...data, - }, - }); - res.status(200).json({message: 'Event created successfully'}); - } - else if (req.method == "PATCH") { - const updateEventType = await prisma.eventType.update({ - where: { - id: req.body.id, - }, - data, - }); - res.status(200).json({message: 'Event updated successfully'}); - } - } - - if (req.method == "DELETE") { - - const deleteEventType = await prisma.eventType.delete({ - where: { - id: req.body.id, }, - }); + createMany: { + data: req.body.customInputs + .filter((input) => !input.id) + .map((input) => ({ + type: input.type, + label: input.label, + required: input.required, + })), + }, + update: req.body.customInputs + .filter((input) => !!input.id) + .map((input) => ({ + data: { + type: input.type, + label: input.label, + required: input.required, + }, + where: { + id: input.id, + }, + })), + }, + }; - res.status(200).json({message: 'Event deleted successfully'}); + if (req.method == "POST") { + await prisma.eventType.create({ + data: { + userId: session.user.id, + ...data, + }, + }); + res.status(200).json({ message: "Event created successfully" }); + } else if (req.method == "PATCH") { + if (req.body.timeZone) { + data.timeZone = req.body.timeZone; + } + + if (req.body.availability) { + const openingHours = req.body.availability.openingHours || []; + // const overrides = req.body.availability.dateOverrides || []; + + await prisma.availability.deleteMany({ + where: { + eventTypeId: +req.body.id, + }, + }); + Promise.all( + openingHours.map((schedule) => + prisma.availability.create({ + data: { + eventTypeId: +req.body.id, + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + }, + }) + ) + ).catch((error) => { + console.log(error); + }); + } + + await prisma.eventType.update({ + where: { + id: req.body.id, + }, + data, + }); + res.status(200).json({ message: "Event updated successfully" }); } + } + + if (req.method == "DELETE") { + await prisma.eventType.delete({ + where: { + id: req.body.id, + }, + }); + + res.status(200).json({ message: "Event deleted successfully" }); + } } diff --git a/pages/api/availability/week.ts b/pages/api/availability/week.ts new file mode 100644 index 0000000000..d52c55d7b7 --- /dev/null +++ b/pages/api/availability/week.ts @@ -0,0 +1,30 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getSession } from 'next-auth/client'; +import prisma from '../../../lib/prisma'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getSession({req: req}); + + if (!session) { + res.status(401).json({message: "Not authenticated"}); + return; + } + + if (req.method == "PATCH") { + + const startMins = req.body.start; + const endMins = req.body.end; + + const updateWeek = await prisma.schedule.update({ + where: { + id: session.user.id, + }, + data: { + startTime: startMins, + endTime: endMins + }, + }); + + res.status(200).json({message: 'Start and end times updated successfully'}); + } +} \ No newline at end of file diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx index ff41c7614c..de3e435339 100644 --- a/pages/availability/event/[type].tsx +++ b/pages/availability/event/[type].tsx @@ -1,17 +1,66 @@ +import { GetServerSideProps } from "next"; import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Select, { OptionBase } from "react-select"; -import prisma from "../../../lib/prisma"; -import { LocationType } from "../../../lib/location"; -import Shell from "../../../components/Shell"; -import { getSession, useSession } from "next-auth/client"; -import { LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon } from "@heroicons/react/outline"; -import { EventTypeCustomInput, EventTypeCustomInputType } from "../../../lib/eventTypeInput"; +import prisma from "@lib/prisma"; +import { LocationType } from "@lib/location"; +import Shell from "@components/Shell"; +import { getSession } from "next-auth/client"; +import { Scheduler } from "@components/ui/Scheduler"; + +import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from "@heroicons/react/outline"; +import { EventTypeCustomInput, EventTypeCustomInputType } from "@lib/eventTypeInput"; import { PlusIcon } from "@heroicons/react/solid"; -export default function EventType(props: any): JSX.Element { +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +dayjs.extend(utc); +import timezone from "dayjs/plugin/timezone"; +import { EventType, User, Availability } from "@prisma/client"; +import { validJson } from "@lib/jsonUtils"; +dayjs.extend(timezone); + +type Props = { + user: User; + eventType: EventType; + locationOptions: OptionBase[]; + availability: Availability[]; +}; + +type OpeningHours = { + days: number[]; + startTime: number; + endTime: number; +}; + +type DateOverride = { + date: string; + startTime: number; + endTime: number; +}; + +type EventTypeInput = { + id: number; + title: string; + slug: string; + description: string; + length: number; + hidden: boolean; + locations: unknown; + eventName: string; + customInputs: EventTypeCustomInput[]; + timeZone: string; + availability?: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }; +}; + +export default function EventTypePage({ + user, + eventType, + locationOptions, + availability, +}: Props): JSX.Element { const router = useRouter(); const inputOptions: OptionBase[] = [ @@ -21,17 +70,17 @@ export default function EventType(props: any): JSX.Element { { value: EventTypeCustomInputType.Bool, label: "Checkbox" }, ]; - const [, loading] = useSession(); + const [enteredAvailability, setEnteredAvailability] = useState(); const [showLocationModal, setShowLocationModal] = useState(false); const [showAddCustomModal, setShowAddCustomModal] = useState(false); + const [selectedTimeZone, setSelectedTimeZone] = useState(""); const [selectedLocation, setSelectedLocation] = useState(undefined); const [selectedInputOption, setSelectedInputOption] = useState(inputOptions[0]); + const [locations, setLocations] = useState(eventType.locations || []); const [selectedCustomInput, setSelectedCustomInput] = useState(undefined); - const [locations, setLocations] = useState(props.eventType.locations || []); const [customInputs, setCustomInputs] = useState( - props.eventType.customInputs.sort((a, b) => a.id - b.id) || [] + eventType.customInputs.sort((a, b) => a.id - b.id) || [] ); - const locationOptions = props.locationOptions; const titleRef = useRef(); const slugRef = useRef(); @@ -40,34 +89,41 @@ export default function EventType(props: any): JSX.Element { const isHiddenRef = useRef(); const eventNameRef = useRef(); - if (loading) { - return

    Loading...

    ; - } + useEffect(() => { + setSelectedTimeZone(eventType.timeZone || user.timeZone); + }, []); async function updateEventTypeHandler(event) { event.preventDefault(); - const enteredTitle = titleRef.current.value; - const enteredSlug = slugRef.current.value; - const enteredDescription = descriptionRef.current.value; - const enteredLength = lengthRef.current.value; - const enteredIsHidden = isHiddenRef.current.checked; - const enteredEventName = eventNameRef.current.value; + const enteredTitle: string = titleRef.current.value; + const enteredSlug: string = slugRef.current.value; + const enteredDescription: string = descriptionRef.current.value; + const enteredLength: number = parseInt(lengthRef.current.value); + const enteredIsHidden: boolean = isHiddenRef.current.checked; + const enteredEventName: string = eventNameRef.current.value; // TODO: Add validation + const payload: EventTypeInput = { + id: eventType.id, + title: enteredTitle, + slug: enteredSlug, + description: enteredDescription, + length: enteredLength, + hidden: enteredIsHidden, + locations, + eventName: enteredEventName, + customInputs, + timeZone: selectedTimeZone, + }; + + if (enteredAvailability) { + payload.availability = enteredAvailability; + } + await fetch("/api/availability/eventtype", { method: "PATCH", - body: JSON.stringify({ - id: props.eventType.id, - title: enteredTitle, - slug: enteredSlug, - description: enteredDescription, - length: enteredLength, - hidden: enteredIsHidden, - locations, - eventName: enteredEventName, - customInputs, - }), + body: JSON.stringify(payload), headers: { "Content-Type": "application/json", }, @@ -81,7 +137,7 @@ export default function EventType(props: any): JSX.Element { await fetch("/api/availability/eventtype", { method: "DELETE", - body: JSON.stringify({ id: props.eventType.id }), + body: JSON.stringify({ id: eventType.id }), headers: { "Content-Type": "application/json", }, @@ -106,6 +162,30 @@ export default function EventType(props: any): JSX.Element { setSelectedCustomInput(undefined); }; + const updateLocations = (e) => { + e.preventDefault(); + + let details = {}; + if (e.target.location.value === LocationType.InPerson) { + details = { address: e.target.address.value }; + } + + const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); + if (existingIdx !== -1) { + const copy = locations; + copy[existingIdx] = { ...locations[existingIdx], ...details }; + setLocations(copy); + } else { + setLocations(locations.concat({ type: e.target.location.value, ...details })); + } + + setShowLocationModal(false); + }; + + const removeLocation = (selectedLocation) => { + setLocations(locations.filter((location) => location.type !== selectedLocation.type)); + }; + const openEditCustomModel = (customInput: EventTypeCustomInput) => { setSelectedCustomInput(customInput); setSelectedInputOption(inputOptions.find((e) => e.value === customInput.type)); @@ -147,30 +227,6 @@ export default function EventType(props: any): JSX.Element { return null; }; - const updateLocations = (e) => { - e.preventDefault(); - - let details = {}; - if (e.target.location.value === LocationType.InPerson) { - details = { address: e.target.address.value }; - } - - const existingIdx = locations.findIndex((loc) => e.target.location.value === loc.type); - if (existingIdx !== -1) { - const copy = locations; - copy[existingIdx] = { ...locations[existingIdx], ...details }; - setLocations(copy); - } else { - setLocations(locations.concat({ type: e.target.location.value, ...details })); - } - - setShowLocationModal(false); - }; - - const removeLocation = (selectedLocation) => { - setLocations(locations.filter((location) => location.type !== selectedLocation.type)); - }; - const updateCustom = (e) => { e.preventDefault(); @@ -207,13 +263,13 @@ export default function EventType(props: any): JSX.Element { return (
    - {props.eventType.title} | Event Type | Calendso + {eventType.title} | Event Type | Calendso - -
    -
    -
    + +
    +
    +
    @@ -229,7 +285,7 @@ export default function EventType(props: any): JSX.Element { required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" - defaultValue={props.eventType.title} + defaultValue={eventType.title} />
    @@ -240,7 +296,7 @@ export default function EventType(props: any): JSX.Element {
    - {location.hostname}/{props.user.username}/ + {typeof location !== "undefined" ? location.hostname : ""}/{user.username}/
    @@ -390,7 +446,7 @@ export default function EventType(props: any): JSX.Element { id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting." - defaultValue={props.eventType.description}> + defaultValue={eventType.description}>
    @@ -406,7 +462,7 @@ export default function EventType(props: any): JSX.Element { required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" - defaultValue={props.eventType.length} + defaultValue={eventType.length} />
    minutes @@ -425,7 +481,7 @@ export default function EventType(props: any): JSX.Element { id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" - defaultValue={props.eventType.eventName} + defaultValue={eventType.eventName} />
    @@ -484,7 +540,7 @@ export default function EventType(props: any): JSX.Element { name="ishidden" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" - defaultChecked={props.eventType.hidden} + defaultChecked={eventType.hidden} />
    @@ -497,12 +553,24 @@ export default function EventType(props: any): JSX.Element {
    - - - Cancel - +
    +
    +

    How do you want to offer your availability for this event type?

    + +
    + + Cancel + + +
    +
    @@ -649,9 +717,7 @@ export default function EventType(props: any): JSX.Element { Is required
    - -