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>
This commit is contained in:
Kartik Labhshetwar
2025-12-11 20:18:00 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot]
parent da4fec9c27
commit 8dc8d87ad0
5 changed files with 160 additions and 12 deletions
@@ -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) {
</>
)}
<div className="font-medium">{t("what")}</div>
<div className="col-span-2 mb-6 break-words last:mb-0" data-testid="booking-title">
{isRoundRobin ? (typeof bookingInfo.title === 'string' ? bookingInfo.title : eventName) : eventName}
<div
className="wrap-break-word col-span-2 mb-6 last:mb-0"
data-testid="booking-title">
{isRoundRobin
? typeof bookingInfo.title === "string"
? bookingInfo.title
: eventName
: eventName}
</div>
<div className="font-medium">{t("when")}</div>
<div className="col-span-2 mb-6 last:mb-0">
@@ -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({
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
</span>
{displayFlags[idx] && (
<>
{" "}
<Badge variant="orange" size="sm">
{t("time_shift")}
</Badge>
</>
)}
</div>
))}
{recurringBookingsSorted.length > 4 && (
@@ -1228,6 +1249,14 @@ function RecurringBookings({
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
</span>
{displayFlags[idx + 4] && (
<>
{" "}
<Badge variant="orange" size="sm">
{t("time_shift")}
</Badge>
</>
)}
</div>
))}
</CollapsibleContent>
@@ -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</0>",
"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",
@@ -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<BookerEvent, "recurringEvent"> }) => {
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<BookerEvent, "recurring
if (!event.recurringEvent) return null;
if (bookerState === "booking" && recurringEventCount && selectedTimeslot) {
const [recurringStrings] = parseRecurringDates(
const [recurringStrings, recurringDates] = parseRecurringDates(
{
startDate: selectedTimeslot,
timeZone: timezone,
@@ -63,15 +69,37 @@ export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurring
},
i18n.language
);
const shiftFlags = getTimeShiftFlags({ dates: recurringDates, timezone });
const displayFlags = getFirstShiftFlags(shiftFlags);
return (
<div data-testid="recurring-dates">
{recurringStrings.slice(0, 5).map((timeFormatted, key) => (
<p key={key}>{timeFormatted}</p>
<p key={key}>
{timeFormatted}
{displayFlags[key] && (
<>
{" "}
<Badge variant="orange" size="sm">
{t("time_shift")}
</Badge>
</>
)}
</p>
))}
{recurringStrings.length > 5 && (
<Tooltip
content={recurringStrings.slice(5).map((timeFormatted, key) => (
<p key={key}>{timeFormatted}</p>
<p key={key}>
{timeFormatted}
{displayFlags[key + 5] && (
<>
{" "}
<Badge variant="orange" size="sm">
{t("time_shift")}
</Badge>
</>
)}
</p>
))}>
<p className=" text-sm">+ {t("plus_more", { count: recurringStrings.length - 5 })}</p>
</Tooltip>
+53
View File
@@ -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]);
});
});
+37
View File
@@ -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;