diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..e49a7e65 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["next/babel"] +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8df24591 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# See http://EditorConfig.org for more information + +# top-most EditorConfig file +root = true + +# Every File +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc.json b/.eslintrc.json index 182300ba..6b0d79e8 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/README.md b/README.md index 6ee88dc5..39964b50 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,9 @@ Let's face it: Calendly and other scheduling tools are awesome. It made our live ### Product of the Month: April #### Support us on [Product Hunt](https://www.producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso) +Calendso - The open source Calendly alternative | Product Hunt Calendso - The open source Calendly alternative | Product Hunt Calendso - The open source Calendly alternative | Product Hunt + -Calendso - The open source Calendly alternative | Product Hunt ### Built With @@ -107,7 +108,7 @@ You will also need Google API credentials. You can get this from the [Google API 5. Set up the database using the Prisma schema (found in `prisma/schema.prisma`) ```sh - npx prisma db push --preview-feature + npx prisma db push ``` 6. Run (in development mode) ```sh @@ -157,7 +158,11 @@ You will also need Google API credentials. You can get this from the [Google API 5. Enjoy the new version. ## Deployment - +### Docker +The Docker configuration for Calendso is an effort powered by people within the community. Calendso does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk. + +The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker). +### Railway [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Fcalendso%2Fcalendso&plugins=postgresql&envs=GOOGLE_API_CREDENTIALS%2CBASE_URL%2CNEXTAUTH_URL%2CPORT&BASE_URLDefault=http%3A%2F%2Flocalhost%3A3000&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000&PORTDefault=3000) You can deploy Calendso on [Railway](https://railway.app/) using the button above. The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Calendso on their platform. diff --git a/components/booking/AvailableTimes.tsx b/components/booking/AvailableTimes.tsx index 6f4e4130..bfa4a0cf 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 00000000..1bea6caf --- /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 00000000..8f92aaeb --- /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 38aafd8b..580e174e 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 00000000..2e890fa8 --- /dev/null +++ b/components/ui/PoweredByCalendso.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; + +const PoweredByCalendso = () => ( + +); + +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 00000000..045c726d --- /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 00000000..a9f371d8 --- /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 00000000..2334802e --- /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 00000000..3f617cb0 --- /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 157b56a6..5beeb9f8 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 c59a83a0..b0ac1af3 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 bf09b987..7f81af14 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 ( @@ -162,10 +94,10 @@ export default function Type(props): Type {
    -
    +
    @@ -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 && ( - - )} + {!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,29 @@ 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"); + + workingHours.sort((a, b) => a.startTime - b.startTime); + return { props: { user, eventType, + workingHours, }, }; }; diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index 38ca732a..614bc9af 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -147,8 +147,8 @@ export default function Book(props: any): JSX.Element { -
    -
    +
    +
    @@ -171,9 +171,9 @@ export default function Book(props: any): JSX.Element { .tz(preferredTimeZone) .format((is24h ? "H:mm" : "h:mma") + ", dddd DD MMMM YYYY")}

    -

    {props.eventType.description}

    +

    {props.eventType.description}

    -
    +
    - -