diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 7c62698369..dcf04f2627 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -1,6 +1,6 @@ import { BookingStatus } from "@prisma/client"; import { useRouter } from "next/router"; -import { useState, useRef } from "react"; +import { useState, useMemo } from "react"; import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; @@ -19,6 +19,7 @@ import MeetingTimeInTimezones from "@calcom/ui/v2/core/MeetingTimeInTimezones"; import showToast from "@calcom/ui/v2/core/notifications"; import useMeQuery from "@lib/hooks/useMeQuery"; +import { extractRecurringDates } from "@lib/parseDate"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; @@ -175,14 +176,16 @@ function BookingListItem(booking: BookingItemProps) { setLocationMutation.mutate({ bookingId: booking.id, newLocation }); }; + // Extract recurring dates is intensive to run, so use useMemo. // Calculate the booking date(s) and setup recurring event data to show - const recurringStrings: string[] = []; - const recurringDates: Date[] = []; - // @FIXME: This is importing the RRULE library which is already heavy. Find out a more optimal way do this. - // if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) { - // [recurringStrings, recurringDates] = extractRecurringDates(booking, user?.timeZone, i18n); - // } + const [recurringStrings, recurringDates] = useMemo(() => { + if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) { + return extractRecurringDates(booking, user?.timeZone, i18n); + } + return [[], []]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.timeZone, i18n.language, booking.recurringBookings]); const location = booking.location || ""; @@ -291,11 +294,7 @@ function BookingListItem(booking: BookingItemProps) { )}
- +
@@ -333,11 +332,7 @@ function BookingListItem(booking: BookingItemProps) { )}
- +
@@ -398,17 +393,18 @@ function BookingListItem(booking: BookingItemProps) { interface RecurringBookingsTooltipProps { booking: BookingItemProps; - recurringStrings: string[]; recurringDates: Date[]; } -const RecurringBookingsTooltip = ({ - booking, - recurringStrings, - recurringDates, -}: RecurringBookingsTooltipProps) => { +const RecurringBookingsTooltip = ({ booking, recurringDates }: RecurringBookingsTooltipProps) => { + // Get user so we can determine 12/24 hour format preferences + const query = useMeQuery(); + const user = query.data; const { t } = useLocale(); const now = new Date(); + const recurringCount = recurringDates.filter((date) => { + return date >= now; + }).length; return ( (booking.recurringBookings && @@ -419,9 +415,11 @@ const RecurringBookingsTooltip = ({
( + content={recurringDates.map((aDate, key) => (

- {aDate} + {formatTime(booking.startTime, user?.timeFormat, user?.timeZone)} + {" - "} + {dayjs(aDate).format("D MMMM YYYY")}

))}>
@@ -432,14 +430,12 @@ const RecurringBookingsTooltip = ({

{booking.status === BookingStatus.ACCEPTED ? `${t("event_remaining", { - count: recurringDates.length, + count: recurringCount, })}` : getEveryFreqFor({ t, recurringEvent: booking.eventType.recurringEvent, - recurringCount: recurringDates.filter((date) => { - return date >= now; - }).length, + recurringCount, })}

diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index a07f30bd3a..6d5d0b5653 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -31,6 +31,7 @@ import { } from "@calcom/embed-core/embed-iframe"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; +import { formatTime } from "@calcom/lib/date-fns"; import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; @@ -46,6 +47,7 @@ import { Button } from "@calcom/ui/v2"; import { asStringOrNull } from "@lib/asStringOrNull"; import { timeZone } from "@lib/clock"; import { ensureArray } from "@lib/ensureArray"; +import useMeQuery from "@lib/hooks/useMeQuery"; import createBooking from "@lib/mutations/bookings/create-booking"; import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking"; import { parseDate, parseRecurringDates } from "@lib/parseDate"; @@ -86,6 +88,9 @@ const BookingPage = ({ ...restProps }: BookingPageProps) => { const { t, i18n } = useLocale(); + // Get user so we can determine 12/24 hour format preferences + const query = useMeQuery(); + const user = query.data; const isEmbed = useIsEmbed(restProps.isEmbed); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; const shouldAlignCentrally = !isEmbed || shouldAlignCentrallyInEmbed; @@ -493,15 +498,25 @@ const BookingPage = ({
{(rescheduleUid || !eventType.recurringEvent?.freq) && - parseDate(dayjs(date).tz(timeZone()), i18n)} + `${formatTime(dayjs(date).toDate(), user?.timeFormat, user?.timeZone)}, ${dayjs( + date + ).format("dddd, D MMMM YYYY")}`} {!rescheduleUid && eventType.recurringEvent?.freq && - recurringStrings.slice(0, 5).map((aDate, key) =>

{aDate}

)} + recurringDates.slice(0, 5).map((aDate, key) => { + return ( +

{`${formatTime(aDate, user?.timeFormat, user?.timeZone)}, ${dayjs( + aDate + ).format("dddd, D MMMM YYYY")}`}

+ ); + })} {!rescheduleUid && eventType.recurringEvent?.freq && recurringStrings.length > 5 && (
( -

{aDate}

+ content={recurringDates.slice(5).map((aDate, key) => ( +

{`${formatTime(aDate, user?.timeFormat, user?.timeZone)}, ${dayjs( + aDate + ).format("dddd, D MMMM YYYY")}`}

))}>

{t("plus_more", { count: recurringStrings.length - 5 })} diff --git a/apps/web/lib/parseDate.ts b/apps/web/lib/parseDate.ts index 601d5e7ec5..ee9ab30342 100644 --- a/apps/web/lib/parseDate.ts +++ b/apps/web/lib/parseDate.ts @@ -20,6 +20,15 @@ export const parseDate = (date: string | null | Dayjs, i18n: I18n) => { return processDate(date, i18n); }; +// tzid is currently broken in rrule library. +// @see https://github.com/jakubroztocil/rrule/issues/523 +const dateWithZone = (d: Date, timeZone?: string) => { + const dateInLocalTZ = new Date(d.toLocaleString("en-US", { timeZone: "UTC" })); + const dateInTargetTZ = new Date(d.toLocaleString("en-US", { timeZone: timeZone || "UTC" })); + const tzOffset = dateInTargetTZ.getTime() - dateInLocalTZ.getTime(); + return new Date(d.getTime() - tzOffset); +}; + export const parseRecurringDates = ( { startDate, @@ -39,22 +48,16 @@ export const parseRecurringDates = ( const rule = new RRule({ ...restRecurringEvent, count: recurringCount, - dtstart: new Date( - Date.UTC( - dayjs.utc(startDate).get("year"), - dayjs.utc(startDate).get("month"), - dayjs.utc(startDate).get("date"), - dayjs.utc(startDate).get("hour"), - dayjs.utc(startDate).get("minute") - ) - ), + dtstart: dayjs(startDate).utc(true).toDate(), + }); + // UTC times with tzOffset applied to account for DST + const times = rule.all().map((t) => dateWithZone(t, timeZone)); + const dateStrings = times.map((t) => { + // undo DST diffs for localized display. + return processDate(dayjs.utc(t).tz(timeZone), i18n); }); - const utcOffset = dayjs(startDate).tz(timeZone).utcOffset(); - const dateStrings = rule.all().map((r) => { - return processDate(dayjs.utc(r).utcOffset(utcOffset), i18n); - }); - return [dateStrings, rule.all()]; + return [dateStrings, times]; }; export const extractRecurringDates = ( @@ -66,6 +69,7 @@ export const extractRecurringDates = ( timeZone: string | undefined, i18n: I18n ): [string[], Date[]] => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { count = 0, ...rest } = booking.eventType.recurringEvent !== null ? booking.eventType.recurringEvent : {}; const recurringInfo = booking.recurringBookings.find( diff --git a/apps/web/pages/success.tsx b/apps/web/pages/success.tsx index 0f1af44a7a..a66f561ac2 100644 --- a/apps/web/pages/success.tsx +++ b/apps/web/pages/success.tsx @@ -638,8 +638,6 @@ export function RecurringBookings({ : null; if (recurringBookingsSorted && listingStatus === "recurring") { - // recurring bookings should only be adjusted to the start date. - const utcOffset = dayjs(recurringBookingsSorted[0]).utcOffset(); return ( <> {eventType.recurringEvent?.count && ( @@ -654,10 +652,9 @@ export function RecurringBookings({ {eventType.recurringEvent?.count && recurringBookingsSorted.slice(0, 4).map((dateStr, idx) => (

- {dayjs(dateStr).utcOffset(utcOffset).format("MMMM DD, YYYY")} + {dayjs(dateStr).format("MMMM DD, YYYY")}
- {dayjs(dateStr).utcOffset(utcOffset).format("LT")} -{" "} - {dayjs(dateStr).utcOffset(utcOffset).add(eventType.length, "m").format("LT")}{" "} + {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) @@ -674,10 +671,9 @@ export function RecurringBookings({ {eventType.recurringEvent?.count && recurringBookingsSorted.slice(4).map((dateStr, idx) => (
- {dayjs(dateStr).utcOffset(utcOffset).format("MMMM DD, YYYY")} + {dayjs(dateStr).format("MMMM DD, YYYY")}
- {dayjs(dateStr).utcOffset(utcOffset).format("LT")} -{" "} - {dayjs(dateStr).utcOffset(utcOffset).add(eventType.length, "m").format("LT")}{" "} + {dayjs(dateStr).format("LT")} - {dayjs(dateStr).add(eventType.length, "m").format("LT")}{" "} ({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()}) diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 4e0609eac4..4146e92d5d 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -333,6 +333,42 @@ async function main() { endTime: dayjs().add(1, "day").add(5, "week").add(30, "minutes").toDate(), status: BookingStatus.ACCEPTED, }, + { + uid: uuid(), + title: "Seeded Yoga class", + description: "seeded", + recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + startTime: dayjs().subtract(4, "day").toDate(), + endTime: dayjs().subtract(4, "day").add(30, "minutes").toDate(), + status: BookingStatus.ACCEPTED, + }, + { + uid: uuid(), + title: "Seeded Yoga class", + description: "seeded", + recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + startTime: dayjs().subtract(4, "day").toDate(), + endTime: dayjs().subtract(4, "day").add(1, "week").add(30, "minutes").toDate(), + status: BookingStatus.ACCEPTED, + }, + { + uid: uuid(), + title: "Seeded Yoga class", + description: "seeded", + recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + startTime: dayjs().subtract(4, "day").toDate(), + endTime: dayjs().subtract(4, "day").add(2, "week").add(30, "minutes").toDate(), + status: BookingStatus.ACCEPTED, + }, + { + uid: uuid(), + title: "Seeded Yoga class", + description: "seeded", + recurringEventId: Buffer.from("seeded-yoga-class").toString("base64"), + startTime: dayjs().subtract(4, "day").toDate(), + endTime: dayjs().subtract(4, "day").add(3, "week").add(30, "minutes").toDate(), + status: BookingStatus.ACCEPTED, + }, ], }, {