diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 62974df16f..d4672046dc 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,10 +1,11 @@ import { BookingStatus } from "@prisma/client"; import { useRouter } from "next/router"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import classNames from "@calcom/lib/classNames"; +import { formatTime } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import showToast from "@calcom/lib/notification"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; @@ -15,10 +16,10 @@ import { Tooltip } from "@calcom/ui/Tooltip"; import { TextArea } from "@calcom/ui/form/fields"; import Badge from "@calcom/ui/v2/core/Badge"; import Button from "@calcom/ui/v2/core/Button"; +import MeetingTimeInTimezones from "@calcom/ui/v2/core/MeetingTimeInTimezones"; import useMeQuery from "@lib/hooks/useMeQuery"; -// import { extractRecurringDates } from "@lib/parseDate"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import TableActions, { ActionType } from "@components/ui/TableActions"; @@ -185,7 +186,7 @@ function BookingListItem(booking: BookingItemProps) { const location = booking.location || ""; - const onClick = () => { + const onClickTableData = () => { router.push({ pathname: "/success", query: { @@ -207,8 +208,6 @@ function BookingListItem(booking: BookingItemProps) { }); }; - const utcOffset = dayjs().tz(user?.timeZone).utcOffset(); - return ( <> - +
{startTime}
- {dayjs - .utc(booking.startTime) - .utcOffset(utcOffset) - .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "} - -{" "} - {dayjs - .utc(booking.endTime) - .utcOffset(utcOffset) - .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} + {formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} + {formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} +
{isPending && ( @@ -298,21 +299,21 @@ function BookingListItem(booking: BookingItemProps) {
- + {/* Time and Badges for mobile */}
{startTime}
- {dayjs - .utc(booking.startTime) - .utcOffset(utcOffset) - .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}{" "} - -{" "} - {dayjs - .utc(booking.endTime) - .utcOffset(utcOffset) - .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} + {formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} -{" "} + {formatTime(booking.endTime, user?.timeFormat, user?.timeZone)} +
diff --git a/packages/lib/date-fns/index.ts b/packages/lib/date-fns/index.ts index 4f59201147..af5f6450af 100644 --- a/packages/lib/date-fns/index.ts +++ b/packages/lib/date-fns/index.ts @@ -6,3 +6,60 @@ export const yyyymmdd = (date: Date | Dayjs) => export const daysInMonth = (date: Date | Dayjs) => date instanceof Date ? dayjs(date).daysInMonth() : date.daysInMonth(); + +/** + * Expects timeFormat to be either 12 or 24, if null or undefined + * is passed in, we always default back to 24 hour notation. + */ +export const formatTime = ( + date: string | Date | Dayjs, + timeFormat?: number | null, + timeZone?: string | null +) => + timeZone + ? dayjs(date) + .tz(timeZone) + .format(timeFormat === 12 ? "h:mma" : "HH:mm") + : dayjs(date).format(timeFormat === 12 ? "h:mma" : "HH:mm"); + +/** + * Sorts two timezones by their offset from GMT. + */ +export const sortByTimezone = (timezoneA: string, timezoneB: string) => { + const timezoneAGmtOffset = dayjs.utc().tz(timezoneA).utcOffset(); + const timezoneBGmtOffset = dayjs.utc().tz(timezoneB).utcOffset(); + + if (timezoneAGmtOffset === timezoneBGmtOffset) return 0; + + return timezoneAGmtOffset < timezoneBGmtOffset ? -1 : 1; +}; + +/** + * Verifies given time is a day before in timezoneB. + */ +export const isPreviousDayInTimezone = (time: string, timezoneA: string, timezoneB: string) => { + const timeInTimezoneA = formatTime(time, 24, timezoneA); + const timeInTimezoneB = formatTime(time, 24, timezoneB); + if (time === timeInTimezoneB) return false; + + // Eg timeInTimezoneA = 12:00 and timeInTimezoneB = 23:00 + const hoursTimezoneBIsLater = timeInTimezoneB.localeCompare(timeInTimezoneA) === 1; + // If it is 23:00, does timezoneA come before or after timezoneB in GMT? + const timezoneBIsEarlierTimezone = sortByTimezone(timezoneA, timezoneB) === 1; + return hoursTimezoneBIsLater && timezoneBIsEarlierTimezone; +}; + +/** + * Verifies given time is a day after in timezoneB. + */ +export const isNextDayInTimezone = (time: string, timezoneA: string, timezoneB: string) => { + const timeInTimezoneA = formatTime(time, 24, timezoneA); + const timeInTimezoneB = formatTime(time, 24, timezoneB); + if (time === timeInTimezoneB) return false; + + // Eg timeInTimezoneA = 12:00 and timeInTimezoneB = 09:00 + const hoursTimezoneBIsEarlier = timeInTimezoneB.localeCompare(timeInTimezoneA) === -1; + // If it is 09:00, does timezoneA come before or after timezoneB in GMT? + const timezoneBIsLaterTimezone = sortByTimezone(timezoneA, timezoneB) === -1; + return hoursTimezoneBIsEarlier && timezoneBIsLaterTimezone; +}; diff --git a/packages/ui/v2/core/MeetingTimeInTimezones.tsx b/packages/ui/v2/core/MeetingTimeInTimezones.tsx new file mode 100644 index 0000000000..cb85b18fee --- /dev/null +++ b/packages/ui/v2/core/MeetingTimeInTimezones.tsx @@ -0,0 +1,94 @@ +import * as Popover from "@radix-ui/react-popover"; + +import { + formatTime, + isNextDayInTimezone, + isPreviousDayInTimezone, + sortByTimezone, +} from "@calcom/lib/date-fns"; + +import { Icon } from "../../Icon"; +import { Attendee } from ".prisma/client"; + +interface MeetingTimeInTimezonesProps { + attendees: Attendee[]; + userTimezone?: string; + timeFormat?: number | null; + startTime: string; + endTime: string; +} + +const MeetingTimeInTimezones = ({ + attendees, + userTimezone, + timeFormat, + startTime, + endTime, +}: MeetingTimeInTimezonesProps) => { + if (!userTimezone || !attendees.length) return null; + + const attendeeTimezones = attendees.map((attendee) => attendee.timeZone); + const uniqueTimezones = [userTimezone, ...attendeeTimezones].filter( + (value, index, self) => self.indexOf(value) === index + ); + + // Convert times to time in timezone, and then sort from earliest to latest time in timezone. + const times = uniqueTimezones + .map((timezone) => { + const isPreviousDay = isPreviousDayInTimezone(startTime, userTimezone, timezone); + const isNextDay = isNextDayInTimezone(startTime, userTimezone, timezone); + return { + startTime: formatTime(startTime, timeFormat, timezone), + endTime: formatTime(endTime, timeFormat, timezone), + timezone, + isPreviousDay, + isNextDay, + }; + }) + .sort((timeA, timeB) => sortByTimezone(timeA.timezone, timeB.timezone)); + + // We don't show the popover if there's only one timezone. + if (times.length === 1) return null; + + return ( + + + + + + + {times.map((time) => ( + + + {time.startTime} - {time.endTime} + {(time.isNextDay || time.isPreviousDay) && ( + + {time.isNextDay ? "+1" : "-1"} + + )} + +
+ {time.timezone} +
+ ))} +
+
+
+ ); +}; + +MeetingTimeInTimezones.displayName = "MeetingTimeInTimezones"; + +// Prevents propagation so the click on eg booking overview won't +// bubble to the row of the table, causing a navigation to the +// detaill page. +const preventBubbling = (event: React.MouseEvent) => { + event.stopPropagation(); +}; + +export default MeetingTimeInTimezones; diff --git a/packages/ui/v2/core/index.ts b/packages/ui/v2/core/index.ts index e8622b46d2..1a7feb071c 100644 --- a/packages/ui/v2/core/index.ts +++ b/packages/ui/v2/core/index.ts @@ -11,6 +11,7 @@ export { default as Dropdown } from "./Dropdown"; export * from "./Dropdown"; export { default as EmptyScreen } from "./EmptyScreen"; export { default as Loader } from "./Loader"; +export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones"; export { default as PageHeader } from "./PageHeader"; export { default as Shell } from "./Shell"; export { default as Stepper } from "./Stepper";