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:
co-authored by
cubic-dev-ai[bot]
parent
da4fec9c27
commit
8dc8d87ad0
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user