Skip to content

Commit 8dc8d87

Browse files
feat: add time shift badge for recurring events with shifted local times (calcom#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>
1 parent da4fec9 commit 8dc8d87

5 files changed

Lines changed: 160 additions & 12 deletions

File tree

apps/web/modules/bookings/views/bookings-single-view.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
3838
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
3939
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
4040
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
41+
import { getTimeShiftFlags, getFirstShiftFlags } from "@calcom/lib/timeShift";
4142
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
4243
import { localStorage } from "@calcom/lib/webstorage";
4344
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
@@ -571,8 +572,14 @@ export default function Success(props: PageProps) {
571572
</>
572573
)}
573574
<div className="font-medium">{t("what")}</div>
574-
<div className="col-span-2 mb-6 break-words last:mb-0" data-testid="booking-title">
575-
{isRoundRobin ? (typeof bookingInfo.title === 'string' ? bookingInfo.title : eventName) : eventName}
575+
<div
576+
className="wrap-break-word col-span-2 mb-6 last:mb-0"
577+
data-testid="booking-title">
578+
{isRoundRobin
579+
? typeof bookingInfo.title === "string"
580+
? bookingInfo.title
581+
: eventName
582+
: eventName}
576583
</div>
577584
<div className="font-medium">{t("when")}</div>
578585
<div className="col-span-2 mb-6 last:mb-0">
@@ -1163,6 +1170,12 @@ function RecurringBookings({
11631170
if (!duration) return null;
11641171

11651172
if (recurringBookingsSorted && allRemainingBookings) {
1173+
const shiftFlags = getTimeShiftFlags({
1174+
dates: recurringBookingsSorted,
1175+
timezone: tz,
1176+
});
1177+
const displayFlags = getFirstShiftFlags(shiftFlags);
1178+
11661179
return (
11671180
<>
11681181
{eventType.recurringEvent?.count && (
@@ -1197,6 +1210,14 @@ function RecurringBookings({
11971210
<span className="text-bookinglight">
11981211
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
11991212
</span>
1213+
{displayFlags[idx] && (
1214+
<>
1215+
{" "}
1216+
<Badge variant="orange" size="sm">
1217+
{t("time_shift")}
1218+
</Badge>
1219+
</>
1220+
)}
12001221
</div>
12011222
))}
12021223
{recurringBookingsSorted.length > 4 && (
@@ -1228,6 +1249,14 @@ function RecurringBookings({
12281249
<span className="text-bookinglight">
12291250
({formatToLocalizedTimezone(dayjs.utc(dateStr), language, tz)})
12301251
</span>
1252+
{displayFlags[idx + 4] && (
1253+
<>
1254+
{" "}
1255+
<Badge variant="orange" size="sm">
1256+
{t("time_shift")}
1257+
</Badge>
1258+
</>
1259+
)}
12311260
</div>
12321261
))}
12331262
</CollapsibleContent>

apps/web/public/static/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"no_available_users_found_error": "No available users found. Could you try another time slot?",
126126
"timeslot_unavailable_book_a_new_time": "The selected time slot is no longer available. <0>Please select a new time</0>",
127127
"timeslot_unavailable_short": "Taken",
128+
"time_shift": "Time shift",
128129
"just_connected_description": "You’ve just connected. Please book from above slots or retry later.",
129130
"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.",
130131
"unavailable_timeslot_title": "Unavailable timeslot",

packages/features/bookings/components/event-meta/Occurences.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import type { BookerEvent } from "@calcom/features/bookings/types";
55
import { useLocale } from "@calcom/lib/hooks/useLocale";
66
import { parseRecurringDates } from "@calcom/lib/parse-dates";
77
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
8+
import { getTimeShiftFlags, getFirstShiftFlags } from "@calcom/lib/timeShift";
89
import { Alert } from "@calcom/ui/components/alert";
10+
import { Badge } from "@calcom/ui/components/badge";
911
import { Input } from "@calcom/ui/components/form";
1012
import { Tooltip } from "@calcom/ui/components/tooltip";
1113

@@ -14,13 +16,17 @@ import { useBookerTime } from "../../Booker/components/hooks/useBookerTime";
1416
export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurringEvent"> }) => {
1517
const maxOccurences = event.recurringEvent?.count || null;
1618
const { t, i18n } = useLocale();
17-
const [setRecurringEventCount, recurringEventCount, setRecurringEventCountQueryParam, recurringEventCountQueryParam] =
18-
useBookerStoreContext((state) => [
19-
state.setRecurringEventCount,
20-
state.recurringEventCount,
21-
state.setRecurringEventCountQueryParam,
22-
state.recurringEventCountQueryParam,
23-
]);
19+
const [
20+
setRecurringEventCount,
21+
recurringEventCount,
22+
setRecurringEventCountQueryParam,
23+
recurringEventCountQueryParam,
24+
] = useBookerStoreContext((state) => [
25+
state.setRecurringEventCount,
26+
state.recurringEventCount,
27+
state.setRecurringEventCountQueryParam,
28+
state.recurringEventCountQueryParam,
29+
]);
2430
const selectedTimeslot = useBookerStoreContext((state) => state.selectedTimeslot);
2531
const bookerState = useBookerStoreContext((state) => state.state);
2632
const { timezone, timeFormat } = useBookerTime();
@@ -53,7 +59,7 @@ export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurring
5359
if (!event.recurringEvent) return null;
5460

5561
if (bookerState === "booking" && recurringEventCount && selectedTimeslot) {
56-
const [recurringStrings] = parseRecurringDates(
62+
const [recurringStrings, recurringDates] = parseRecurringDates(
5763
{
5864
startDate: selectedTimeslot,
5965
timeZone: timezone,
@@ -63,15 +69,37 @@ export const EventOccurences = ({ event }: { event: Pick<BookerEvent, "recurring
6369
},
6470
i18n.language
6571
);
72+
const shiftFlags = getTimeShiftFlags({ dates: recurringDates, timezone });
73+
const displayFlags = getFirstShiftFlags(shiftFlags);
6674
return (
6775
<div data-testid="recurring-dates">
6876
{recurringStrings.slice(0, 5).map((timeFormatted, key) => (
69-
<p key={key}>{timeFormatted}</p>
77+
<p key={key}>
78+
{timeFormatted}
79+
{displayFlags[key] && (
80+
<>
81+
{" "}
82+
<Badge variant="orange" size="sm">
83+
{t("time_shift")}
84+
</Badge>
85+
</>
86+
)}
87+
</p>
7088
))}
7189
{recurringStrings.length > 5 && (
7290
<Tooltip
7391
content={recurringStrings.slice(5).map((timeFormatted, key) => (
74-
<p key={key}>{timeFormatted}</p>
92+
<p key={key}>
93+
{timeFormatted}
94+
{displayFlags[key + 5] && (
95+
<>
96+
{" "}
97+
<Badge variant="orange" size="sm">
98+
{t("time_shift")}
99+
</Badge>
100+
</>
101+
)}
102+
</p>
75103
))}>
76104
<p className=" text-sm">+ {t("plus_more", { count: recurringStrings.length - 5 })}</p>
77105
</Tooltip>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import dayjs from "@calcom/dayjs";
4+
5+
import { getTimeShiftFlags } from "../timeShift";
6+
7+
describe("getTimeShiftFlags", () => {
8+
it("returns empty array for no dates", () => {
9+
expect(getTimeShiftFlags({ dates: [], timezone: "Europe/Berlin" })).toEqual([]);
10+
});
11+
12+
it("marks only shifting occurrences as true for a DST forward change", () => {
13+
const tz = "Europe/Berlin";
14+
15+
const first = dayjs.tz("2026-03-17T01:00:00", tz);
16+
const second = dayjs.tz("2026-03-24T01:00:00", tz);
17+
const third = dayjs.tz("2026-03-31T02:00:00", tz);
18+
19+
const dates = [first.toISOString(), second.toISOString(), third.toISOString()];
20+
21+
const flags = getTimeShiftFlags({ dates, timezone: tz });
22+
23+
expect(flags).toEqual([false, false, true]);
24+
});
25+
26+
it("marks only shifting occurrences as true for a DST backward change", () => {
27+
const tz = "Europe/Berlin";
28+
29+
const first = dayjs.tz("2026-10-18T02:00:00", tz);
30+
const second = dayjs.tz("2026-10-25T02:00:00", tz);
31+
const third = dayjs.tz("2026-11-01T01:00:00", tz);
32+
33+
const dates = [first.toISOString(), second.toISOString(), third.toISOString()];
34+
35+
const flags = getTimeShiftFlags({ dates, timezone: tz });
36+
37+
expect(flags).toEqual([false, false, true]);
38+
});
39+
40+
it("handles non shifting occurrences", () => {
41+
const tz = "America/New_York";
42+
43+
const first = dayjs.tz("2026-04-01T09:00:00", tz);
44+
const second = dayjs.tz("2026-04-08T09:00:00", tz);
45+
const third = dayjs.tz("2026-04-15T09:00:00", tz);
46+
47+
const dates = [first.toISOString(), second.toISOString(), third.toISOString()];
48+
49+
const flags = getTimeShiftFlags({ dates, timezone: tz });
50+
51+
expect(flags).toEqual([false, false, false]);
52+
});
53+
});

packages/lib/timeShift.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import dayjs from "@calcom/dayjs";
2+
3+
/**
4+
* Given a list of UTC datetimes and a target timezone, returns a boolean array
5+
* indicating which occurrences have a *different local start time* (HH:mm)
6+
* than the first occurrence in that timezone.
7+
*
8+
* The first occurrence is always `false` (baseline).
9+
*/
10+
export const getTimeShiftFlags = (options: { dates: (string | Date)[]; timezone: string }): boolean[] => {
11+
const { dates, timezone } = options;
12+
13+
if (!dates.length) return [];
14+
15+
const first = dayjs(dates[0]).tz(timezone);
16+
const baseHour = first.hour();
17+
const baseMinute = first.minute();
18+
19+
return dates.map((date, index) => {
20+
if (index === 0) return false;
21+
22+
const d = dayjs(date).tz(timezone);
23+
24+
return d.hour() !== baseHour || d.minute() !== baseMinute;
25+
});
26+
};
27+
28+
export const getFirstShiftFlags = (shiftFlags: boolean[]): boolean[] => {
29+
let hasSeenShift = false;
30+
return shiftFlags.map((flag) => {
31+
if (!flag || hasSeenShift) return false;
32+
hasSeenShift = true;
33+
return true;
34+
});
35+
};
36+
37+
export default getTimeShiftFlags;

0 commit comments

Comments
 (0)