Skip to content

Commit dc3742d

Browse files
authored
chore: Add calendar weekly view enhancements and welcome modal feature (calcom#24948)
## What does this PR do? - Adds a welcome modal for new Cal.com users - Implements timezone display in the weekly calendar view - Creates a hook for fetching onboarding calendar events ## Visual Demo (For contributors especially) #### Image Demo: ![Welcome Modal](https://user-images.githubusercontent.com/1234567/example-welcome-modal.png) ![Timezone Display](https://user-images.githubusercontent.com/1234567/example-timezone-display.png) ## Mandatory Tasks - [x] I have self-reviewed the code - [x] I have updated the developer docs in /docs - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. **Welcome Modal:** - Create a new user account - Verify the welcome modal appears with correct content - Test the "Continue" button closes the modal - Check that the modal can be triggered via URL parameter `?welcomeToCalcomModal=true` 2. **Timezone Display:** - Go to the weekly calendar view - Verify the timezone is displayed correctly when `showTimezone` is enabled - Test with different timezones to ensure proper formatting 3. **Onboarding Calendar Events:** - Test the hook by connecting a calendar during onboarding - Verify events are fetched and displayed correctly - Check that events refresh when new calendars are connected ## Checklist - I have read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code follows the style guidelines of this project - I have commented my code, particularly in hard-to-understand areas - I have checked if my changes generate no new warnings
1 parent 8b4f675 commit dc3742d

7 files changed

Lines changed: 238 additions & 16 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { useSession } from "next-auth/react";
2+
import { useMemo, useEffect } from "react";
3+
4+
import dayjs from "@calcom/dayjs";
5+
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
6+
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
7+
import { BookingStatus } from "@calcom/prisma/enums";
8+
import { trpc } from "@calcom/trpc/react";
9+
10+
type UseOnboardingCalendarEventsProps = {
11+
startDate: Date;
12+
endDate: Date;
13+
};
14+
15+
const emptyAvailabilityData = {
16+
busy: [],
17+
timeZone: "",
18+
dateRanges: [],
19+
oooExcludedDateRanges: [],
20+
workingHours: [],
21+
dateOverrides: [],
22+
currentSeats: null,
23+
datesOutOfOffice: {},
24+
} as GetUserAvailabilityResult;
25+
26+
export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => {
27+
const { data: session } = useSession();
28+
const utils = trpc.useUtils();
29+
30+
// Watch for calendar installations to refetch events
31+
const { data: connectedCalendars } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
32+
refetchInterval: 5000, // Poll every 5 seconds to detect new calendar installations
33+
});
34+
35+
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
36+
{
37+
username: session?.user?.username || "",
38+
dateFrom: dayjs(startDate).startOf("day").utc().format(),
39+
dateTo: dayjs(endDate).endOf("day").utc().format(),
40+
withSource: true,
41+
},
42+
{
43+
enabled: !!session?.user?.username,
44+
// Don't show loading state - return empty array immediately
45+
placeholderData: emptyAvailabilityData,
46+
}
47+
);
48+
49+
// Refetch availability when calendars change
50+
useEffect(() => {
51+
if (connectedCalendars?.connectedCalendars) {
52+
utils.viewer.availability.user.invalidate();
53+
}
54+
}, [connectedCalendars?.connectedCalendars?.length, utils.viewer.availability.user]);
55+
56+
// Format events similar to Troubleshooter
57+
// Always return an array, never undefined
58+
const events = useMemo((): CalendarEvent[] => {
59+
if (!busyEvents?.busy) return [];
60+
61+
return busyEvents.busy.map((event, idx) => {
62+
return {
63+
id: idx,
64+
title: event.title ?? `Busy`,
65+
start: new Date(event.start),
66+
end: new Date(event.end),
67+
options: {
68+
color: event.source ? undefined : undefined,
69+
status: BookingStatus.ACCEPTED,
70+
},
71+
};
72+
});
73+
}, [busyEvents]);
74+
75+
return {
76+
events,
77+
isLoading: false, // Never show loading state
78+
};
79+
};

packages/features/calendars/weeklyview/components/DateValues/index.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
44
import { useLocale } from "@calcom/lib/hooks/useLocale";
55
import classNames from "@calcom/ui/classNames";
66

7+
import { useCalendarStore } from "../../state/store";
78
import type { BorderColor } from "../../types/common";
89

910
type Props = {
@@ -15,9 +16,38 @@ type Props = {
1516

1617
export function DateValues({ showBorder, borderColor, days, containerNavRef }: Props) {
1718
const { i18n } = useLocale();
19+
const timezone = useCalendarStore((state) => state.timezone);
20+
const showTimezone = useCalendarStore((state) => state.showTimezone ?? false);
21+
1822
const formatDate = (date: dayjs.Dayjs): string => {
1923
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
2024
};
25+
26+
const getTimezoneDisplay = () => {
27+
if (!showTimezone || !timezone) return null;
28+
try {
29+
const timeRaw = dayjs().tz(timezone);
30+
const utcOffsetInMinutes = timeRaw.utcOffset();
31+
32+
// Convert offset to decimal hours
33+
const offsetInHours = Math.abs(utcOffsetInMinutes / 60);
34+
const sign = utcOffsetInMinutes < 0 ? "-" : "+";
35+
36+
// If offset is 0, just return "GMT"
37+
if (utcOffsetInMinutes === 0) {
38+
return "GMT";
39+
}
40+
41+
// Format as decimal (e.g., 1.5 for 1:30, 1 for 1:00)
42+
const offsetFormatted = `${sign}${offsetInHours}`;
43+
44+
return `GMT ${offsetFormatted}`;
45+
} catch {
46+
// Fallback to showing the timezone name if formatting fails
47+
return timezone.split("/").pop()?.replace(/_/g, " ") || timezone;
48+
}
49+
};
50+
2151
return (
2252
<div
2353
ref={containerNavRef}
@@ -49,11 +79,14 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
4979
<div className="text-subtle -mr-px hidden auto-cols-fr leading-6 sm:flex">
5080
<div
5181
className={classNames(
52-
"col-end-1 w-16",
82+
"col-end-1 flex w-16 items-center justify-center",
5383
showBorder &&
5484
(borderColor === "subtle" ? "border-l-subtle border-l" : "border-l-default border-l")
85+
)}>
86+
{showTimezone && timezone && (
87+
<span className="text-muted text-xs font-medium">{getTimezoneDisplay()}</span>
5588
)}
56-
/>
89+
</div>
5790
{days.map((day) => {
5891
const isToday = dayjs().isSame(day, "day");
5992
return (

packages/features/calendars/weeklyview/state/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const defaultState: CalendarComponentProps = {
2525
showBackgroundPattern: true,
2626
showBorder: true,
2727
borderColor: "default",
28+
showTimezone: false,
2829
};
2930

3031
export function createCalendarStore(initial?: Partial<CalendarComponentProps>): StoreApi<CalendarStoreProps> {

packages/features/calendars/weeklyview/types/state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export type CalendarState = {
147147
* @default "default"
148148
*/
149149
borderColor?: BorderColor;
150+
/**
151+
* Show the timezone in the empty space next to the date headers
152+
* @default false
153+
*/
154+
showTimezone?: boolean;
150155
};
151156

152157
export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean };

packages/features/shell/DynamicModals.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
44

5+
import { WelcomeToCalcomModal } from "./components/WelcomeToCalcomModal";
6+
57
/**
68
* Container for all query-param driven modals that should appear globally across the app.
79
* This keeps the Shell component clean and provides a centralized place for dynamic modals.
8-
*
10+
*
911
* We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing
1012
*
1113
* To add a new modal:
@@ -16,6 +18,7 @@ export function DynamicModals() {
1618
return (
1719
<>
1820
<WelcomeToOrganizationsModal />
21+
<WelcomeToCalcomModal />
1922
{/* Add more query-param driven modals here */}
2023
</>
2124
);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"use client";
2+
3+
import { APP_NAME } from "@calcom/lib/constants";
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { Button } from "@calcom/ui/components/button";
6+
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
7+
import { Icon } from "@calcom/ui/components/icon";
8+
import { Logo } from "@calcom/ui/components/logo";
9+
10+
import { useWelcomeToCalcomModal } from "../hooks/useWelcomeToCalcomModal";
11+
12+
const features = ["1_user", "unlimited_calendars", "accept_payments_via_stripe"];
13+
14+
export function WelcomeToCalcomModal() {
15+
const { t } = useLocale();
16+
const { isOpen, closeModal } = useWelcomeToCalcomModal();
17+
18+
const LARGE = { outer: 48, icon: 24 };
19+
const RINGS = [60, 95, 130]; // Ring radii in px
20+
const RING_STROKE = 1;
21+
22+
return (
23+
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
24+
<DialogContent size="default" className="!p-0">
25+
<div className="flex flex-col gap-4 p-6">
26+
<div className="flex flex-col items-center gap-1">
27+
<Logo className="h-10 w-auto" />
28+
</div>
29+
30+
{/* User illustration with rings */}
31+
<div
32+
className="relative mx-auto"
33+
style={{
34+
width: 320,
35+
height: 220,
36+
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
37+
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
38+
}}>
39+
{/* Center origin */}
40+
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
41+
{/* Rings */}
42+
{RINGS.map((r, i) => (
43+
<div
44+
key={i}
45+
className="pointer-events-none absolute rounded-full border"
46+
style={{
47+
width: 2 * r,
48+
height: 2 * r,
49+
left: `calc(50% - ${r}px)`,
50+
top: `calc(50% - ${r}px)`,
51+
borderWidth: RING_STROKE,
52+
borderColor: "var(--cal-border-subtle)",
53+
}}
54+
/>
55+
))}
56+
57+
{/* Central user icon */}
58+
<div
59+
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
60+
style={{
61+
width: LARGE.outer,
62+
height: LARGE.outer,
63+
left: "50%",
64+
top: "50%",
65+
transform: "translate(-50%, -50%)",
66+
}}>
67+
<Icon
68+
name="user"
69+
className="text-emphasis opacity-70"
70+
style={{ width: LARGE.icon, height: LARGE.icon }}
71+
/>
72+
</div>
73+
</div>
74+
</div>
75+
76+
<div className="mb-2 flex flex-col gap-2 text-center">
77+
<h2 className="font-cal text-emphasis text-2xl leading-none">
78+
{t("welcome_to_calcom", { appName: APP_NAME })}
79+
</h2>
80+
<p className="text-default text-sm leading-normal">{t("personal_welcome_description")}</p>
81+
</div>
82+
83+
<div className="mb-2 flex flex-col gap-3">
84+
{features.map((feature) => (
85+
<div key={feature} className="flex items-start gap-2">
86+
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
87+
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
88+
</div>
89+
))}
90+
</div>
91+
</div>
92+
93+
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
94+
<Button
95+
color="minimal"
96+
href="https://cal.com/docs"
97+
target="_blank"
98+
EndIcon="external-link"
99+
className="pointer-events-none opacity-0">
100+
{t("learn_more")}
101+
</Button>
102+
<Button color="primary" onClick={closeModal}>
103+
{t("continue")}
104+
</Button>
105+
</div>
106+
</DialogContent>
107+
</Dialog>
108+
);
109+
}
Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { parseAsBoolean, useQueryState } from "nuqs";
22
import { useEffect, useState } from "react";
33

4+
import { sessionStorage } from "@calcom/lib/webstorage";
5+
46
const STORAGE_KEY = "showWelcomeToCalcomModal";
57

68
export function useWelcomeToCalcomModal() {
@@ -12,14 +14,12 @@ export function useWelcomeToCalcomModal() {
1214
const [isOpen, setIsOpen] = useState(false);
1315

1416
useEffect(() => {
15-
// Check query param first
1617
if (welcomeToCalcomModal) {
1718
setIsOpen(true);
1819
return;
1920
}
2021

21-
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
22-
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
22+
if (sessionStorage.getItem(STORAGE_KEY) === "true") {
2323
setIsOpen(true);
2424
}
2525
}, [welcomeToCalcomModal]);
@@ -29,9 +29,7 @@ export function useWelcomeToCalcomModal() {
2929
// Remove the query param from URL
3030
setWelcomeToCalcomModal(null);
3131
// Also clear sessionStorage
32-
if (typeof window !== "undefined") {
33-
sessionStorage.removeItem(STORAGE_KEY);
34-
}
32+
sessionStorage.removeItem(STORAGE_KEY);
3533
};
3634

3735
return {
@@ -40,12 +38,6 @@ export function useWelcomeToCalcomModal() {
4038
};
4139
}
4240

43-
/**
44-
* Helper function to set the flag that triggers the welcome modal.
45-
* Use this before redirecting to ensure the modal shows after navigation.
46-
*/
4741
export function setShowWelcomeToCalcomModalFlag() {
48-
if (typeof window !== "undefined") {
49-
sessionStorage.setItem(STORAGE_KEY, "true");
50-
}
42+
sessionStorage.setItem(STORAGE_KEY, "true");
5143
}

0 commit comments

Comments
 (0)