From 8dc8d87ad04a1cf55630646a2cec7fd09883202d Mon Sep 17 00:00:00 2001 From: Kartik Labhshetwar Date: Fri, 12 Dec 2025 01:48:00 +0530 Subject: [PATCH] feat: add time shift badge for recurring events with shifted local times (#25568) * feat: highlight recurring bookings with time shift badge across DST * Update packages/lib/__tests__/timeShift.test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: show time shift badge only on first shift in bookings view and occurrences * refactor: update recurring bookings display logic and wrap booking title text * refactor: add getFirstShiftFlags helper for time shift flags and update components --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../bookings/views/bookings-single-view.tsx | 33 +++++++++++- apps/web/public/static/locales/en/common.json | 1 + .../components/event-meta/Occurences.tsx | 48 +++++++++++++---- packages/lib/__tests__/timeShift.test.ts | 53 +++++++++++++++++++ packages/lib/timeShift.ts | 37 +++++++++++++ 5 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 packages/lib/__tests__/timeShift.test.ts create mode 100644 packages/lib/timeShift.ts diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 5734dc7f03..04505d7469 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -38,6 +38,7 @@ import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat"; +import { getTimeShiftFlags, getFirstShiftFlags } from "@calcom/lib/timeShift"; import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { localStorage } from "@calcom/lib/webstorage"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; @@ -571,8 +572,14 @@ export default function Success(props: PageProps) { )}
{t("what")}
-
- {isRoundRobin ? (typeof bookingInfo.title === 'string' ? bookingInfo.title : eventName) : eventName} +
+ {isRoundRobin + ? typeof bookingInfo.title === "string" + ? bookingInfo.title + : eventName + : eventName}
{t("when")}
@@ -1163,6 +1170,12 @@ function RecurringBookings({ if (!duration) return null; if (recurringBookingsSorted && allRemainingBookings) { + const shiftFlags = getTimeShiftFlags({ + dates: recurringBookingsSorted, + timezone: tz, + }); + const displayFlags = getFirstShiftFlags(shiftFlags); + return ( <> {eventType.recurringEvent?.count && ( @@ -1197,6 +1210,14 @@ function RecurringBookings({ ({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)}) + {displayFlags[idx] && ( + <> + {" "} + + {t("time_shift")} + + + )}
))} {recurringBookingsSorted.length > 4 && ( @@ -1228,6 +1249,14 @@ function RecurringBookings({ ({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)}) + {displayFlags[idx + 4] && ( + <> + {" "} + + {t("time_shift")} + + + )}
))} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 35770754e0..558fa5c98b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -125,6 +125,7 @@ "no_available_users_found_error": "No available users found. Could you try another time slot?", "timeslot_unavailable_book_a_new_time": "The selected time slot is no longer available. <0>Please select a new time", "timeslot_unavailable_short": "Taken", + "time_shift": "Time shift", "just_connected_description": "You’ve just connected. Please book from above slots or retry later.", "please_try_again_later_or_book_another_slot": "You've just tried connecting now. Please try again later in {{remaining}} minutes or book another slot from the booking page.", "unavailable_timeslot_title": "Unavailable timeslot", diff --git a/packages/features/bookings/components/event-meta/Occurences.tsx b/packages/features/bookings/components/event-meta/Occurences.tsx index e57d39afdc..81d78161de 100644 --- a/packages/features/bookings/components/event-meta/Occurences.tsx +++ b/packages/features/bookings/components/event-meta/Occurences.tsx @@ -5,7 +5,9 @@ import type { BookerEvent } from "@calcom/features/bookings/types"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { parseRecurringDates } from "@calcom/lib/parse-dates"; import { getRecurringFreq } from "@calcom/lib/recurringStrings"; +import { getTimeShiftFlags, getFirstShiftFlags } from "@calcom/lib/timeShift"; import { Alert } from "@calcom/ui/components/alert"; +import { Badge } from "@calcom/ui/components/badge"; import { Input } from "@calcom/ui/components/form"; import { Tooltip } from "@calcom/ui/components/tooltip"; @@ -14,13 +16,17 @@ import { useBookerTime } from "../../Booker/components/hooks/useBookerTime"; export const EventOccurences = ({ event }: { event: Pick }) => { const maxOccurences = event.recurringEvent?.count || null; const { t, i18n } = useLocale(); - const [setRecurringEventCount, recurringEventCount, setRecurringEventCountQueryParam, recurringEventCountQueryParam] = - useBookerStoreContext((state) => [ - state.setRecurringEventCount, - state.recurringEventCount, - state.setRecurringEventCountQueryParam, - state.recurringEventCountQueryParam, - ]); + const [ + setRecurringEventCount, + recurringEventCount, + setRecurringEventCountQueryParam, + recurringEventCountQueryParam, + ] = useBookerStoreContext((state) => [ + state.setRecurringEventCount, + state.recurringEventCount, + state.setRecurringEventCountQueryParam, + state.recurringEventCountQueryParam, + ]); const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot); const bookerState = useBookerStoreContext((state) => state.state); const { timezone, timeFormat } = useBookerTime(); @@ -53,7 +59,7 @@ export const EventOccurences = ({ event }: { event: Pick {recurringStrings.slice(0, 5).map((timeFormatted, key) => ( -

{timeFormatted}

+

+ {timeFormatted} + {displayFlags[key] && ( + <> + {" "} + + {t("time_shift")} + + + )} +

))} {recurringStrings.length > 5 && ( ( -

{timeFormatted}

+

+ {timeFormatted} + {displayFlags[key + 5] && ( + <> + {" "} + + {t("time_shift")} + + + )} +

))}>

+ {t("plus_more", { count: recurringStrings.length - 5 })}

diff --git a/packages/lib/__tests__/timeShift.test.ts b/packages/lib/__tests__/timeShift.test.ts new file mode 100644 index 0000000000..bfd92dfa6d --- /dev/null +++ b/packages/lib/__tests__/timeShift.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import { getTimeShiftFlags } from "../timeShift"; + +describe("getTimeShiftFlags", () => { + it("returns empty array for no dates", () => { + expect(getTimeShiftFlags({ dates: [], timezone: "Europe/Berlin" })).toEqual([]); + }); + + it("marks only shifting occurrences as true for a DST forward change", () => { + const tz = "Europe/Berlin"; + + const first = dayjs.tz("2026-03-17T01:00:00", tz); + const second = dayjs.tz("2026-03-24T01:00:00", tz); + const third = dayjs.tz("2026-03-31T02:00:00", tz); + + const dates = [first.toISOString(), second.toISOString(), third.toISOString()]; + + const flags = getTimeShiftFlags({ dates, timezone: tz }); + + expect(flags).toEqual([false, false, true]); + }); + + it("marks only shifting occurrences as true for a DST backward change", () => { + const tz = "Europe/Berlin"; + + const first = dayjs.tz("2026-10-18T02:00:00", tz); + const second = dayjs.tz("2026-10-25T02:00:00", tz); + const third = dayjs.tz("2026-11-01T01:00:00", tz); + + const dates = [first.toISOString(), second.toISOString(), third.toISOString()]; + + const flags = getTimeShiftFlags({ dates, timezone: tz }); + + expect(flags).toEqual([false, false, true]); + }); + + it("handles non shifting occurrences", () => { + const tz = "America/New_York"; + + const first = dayjs.tz("2026-04-01T09:00:00", tz); + const second = dayjs.tz("2026-04-08T09:00:00", tz); + const third = dayjs.tz("2026-04-15T09:00:00", tz); + + const dates = [first.toISOString(), second.toISOString(), third.toISOString()]; + + const flags = getTimeShiftFlags({ dates, timezone: tz }); + + expect(flags).toEqual([false, false, false]); + }); +}); diff --git a/packages/lib/timeShift.ts b/packages/lib/timeShift.ts new file mode 100644 index 0000000000..d50472be7c --- /dev/null +++ b/packages/lib/timeShift.ts @@ -0,0 +1,37 @@ +import dayjs from "@calcom/dayjs"; + +/** + * Given a list of UTC datetimes and a target timezone, returns a boolean array + * indicating which occurrences have a *different local start time* (HH:mm) + * than the first occurrence in that timezone. + * + * The first occurrence is always `false` (baseline). + */ +export const getTimeShiftFlags = (options: { dates: (string | Date)[]; timezone: string }): boolean[] => { + const { dates, timezone } = options; + + if (!dates.length) return []; + + const first = dayjs(dates[0]).tz(timezone); + const baseHour = first.hour(); + const baseMinute = first.minute(); + + return dates.map((date, index) => { + if (index === 0) return false; + + const d = dayjs(date).tz(timezone); + + return d.hour() !== baseHour || d.minute() !== baseMinute; + }); +}; + +export const getFirstShiftFlags = (shiftFlags: boolean[]): boolean[] => { + let hasSeenShift = false; + return shiftFlags.map((flag) => { + if (!flag || hasSeenShift) return false; + hasSeenShift = true; + return true; + }); +}; + +export default getTimeShiftFlags;