chore: Add calendar weekly view enhancements and welcome modal feature (#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
This commit is contained in:
sean-brydon
2025-11-11 10:47:22 +00:00
committed by GitHub
parent 8b4f675cee
commit dc3742d322
7 changed files with 238 additions and 16 deletions
@@ -0,0 +1,79 @@
import { useSession } from "next-auth/react";
import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import type { GetUserAvailabilityResult } from "@calcom/features/availability/lib/getUserAvailability";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
type UseOnboardingCalendarEventsProps = {
startDate: Date;
endDate: Date;
};
const emptyAvailabilityData = {
busy: [],
timeZone: "",
dateRanges: [],
oooExcludedDateRanges: [],
workingHours: [],
dateOverrides: [],
currentSeats: null,
datesOutOfOffice: {},
} as GetUserAvailabilityResult;
export const useOnboardingCalendarEvents = ({ startDate, endDate }: UseOnboardingCalendarEventsProps) => {
const { data: session } = useSession();
const utils = trpc.useUtils();
// Watch for calendar installations to refetch events
const { data: connectedCalendars } = trpc.viewer.calendars.connectedCalendars.useQuery(undefined, {
refetchInterval: 5000, // Poll every 5 seconds to detect new calendar installations
});
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
{
username: session?.user?.username || "",
dateFrom: dayjs(startDate).startOf("day").utc().format(),
dateTo: dayjs(endDate).endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!session?.user?.username,
// Don't show loading state - return empty array immediately
placeholderData: emptyAvailabilityData,
}
);
// Refetch availability when calendars change
useEffect(() => {
if (connectedCalendars?.connectedCalendars) {
utils.viewer.availability.user.invalidate();
}
}, [connectedCalendars?.connectedCalendars?.length, utils.viewer.availability.user]);
// Format events similar to Troubleshooter
// Always return an array, never undefined
const events = useMemo((): CalendarEvent[] => {
if (!busyEvents?.busy) return [];
return busyEvents.busy.map((event, idx) => {
return {
id: idx,
title: event.title ?? `Busy`,
start: new Date(event.start),
end: new Date(event.end),
options: {
color: event.source ? undefined : undefined,
status: BookingStatus.ACCEPTED,
},
};
});
}, [busyEvents]);
return {
events,
isLoading: false, // Never show loading state
};
};
@@ -4,6 +4,7 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { useCalendarStore } from "../../state/store";
import type { BorderColor } from "../../types/common";
type Props = {
@@ -15,9 +16,38 @@ type Props = {
export function DateValues({ showBorder, borderColor, days, containerNavRef }: Props) {
const { i18n } = useLocale();
const timezone = useCalendarStore((state) => state.timezone);
const showTimezone = useCalendarStore((state) => state.showTimezone ?? false);
const formatDate = (date: dayjs.Dayjs): string => {
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
};
const getTimezoneDisplay = () => {
if (!showTimezone || !timezone) return null;
try {
const timeRaw = dayjs().tz(timezone);
const utcOffsetInMinutes = timeRaw.utcOffset();
// Convert offset to decimal hours
const offsetInHours = Math.abs(utcOffsetInMinutes / 60);
const sign = utcOffsetInMinutes < 0 ? "-" : "+";
// If offset is 0, just return "GMT"
if (utcOffsetInMinutes === 0) {
return "GMT";
}
// Format as decimal (e.g., 1.5 for 1:30, 1 for 1:00)
const offsetFormatted = `${sign}${offsetInHours}`;
return `GMT ${offsetFormatted}`;
} catch {
// Fallback to showing the timezone name if formatting fails
return timezone.split("/").pop()?.replace(/_/g, " ") || timezone;
}
};
return (
<div
ref={containerNavRef}
@@ -49,11 +79,14 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
<div className="text-subtle -mr-px hidden auto-cols-fr leading-6 sm:flex">
<div
className={classNames(
"col-end-1 w-16",
"col-end-1 flex w-16 items-center justify-center",
showBorder &&
(borderColor === "subtle" ? "border-l-subtle border-l" : "border-l-default border-l")
)}>
{showTimezone && timezone && (
<span className="text-muted text-xs font-medium">{getTimezoneDisplay()}</span>
)}
/>
</div>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -25,6 +25,7 @@ const defaultState: CalendarComponentProps = {
showBackgroundPattern: true,
showBorder: true,
borderColor: "default",
showTimezone: false,
};
export function createCalendarStore(initial?: Partial<CalendarComponentProps>): StoreApi<CalendarStoreProps> {
@@ -147,6 +147,11 @@ export type CalendarState = {
* @default "default"
*/
borderColor?: BorderColor;
/**
* Show the timezone in the empty space next to the date headers
* @default false
*/
showTimezone?: boolean;
};
export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean };
+4 -1
View File
@@ -2,10 +2,12 @@
import { WelcomeToOrganizationsModal } from "@calcom/features/ee/organizations/components/WelcomeToOrganizationsModal";
import { WelcomeToCalcomModal } from "./components/WelcomeToCalcomModal";
/**
* Container for all query-param driven modals that should appear globally across the app.
* This keeps the Shell component clean and provides a centralized place for dynamic modals.
*
*
* We can probably also use this for thinks like the T&C and Privacy Policy modals. That @marketing are discussing
*
* To add a new modal:
@@ -16,6 +18,7 @@ export function DynamicModals() {
return (
<>
<WelcomeToOrganizationsModal />
<WelcomeToCalcomModal />
{/* Add more query-param driven modals here */}
</>
);
@@ -0,0 +1,109 @@
"use client";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { useWelcomeToCalcomModal } from "../hooks/useWelcomeToCalcomModal";
const features = ["1_user", "unlimited_calendars", "accept_payments_via_stripe"];
export function WelcomeToCalcomModal() {
const { t } = useLocale();
const { isOpen, closeModal } = useWelcomeToCalcomModal();
const LARGE = { outer: 48, icon: 24 };
const RINGS = [60, 95, 130]; // Ring radii in px
const RING_STROKE = 1;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
{/* User illustration with rings */}
<div
className="relative mx-auto"
style={{
width: 320,
height: 220,
maskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
WebkitMaskImage: "radial-gradient(ellipse 100% 60% at center, black 30%, transparent 85%)",
}}>
{/* Center origin */}
<div className="absolute left-1/2 top-1/2" style={{ transform: "translate(-50%, -50%)" }}>
{/* Rings */}
{RINGS.map((r, i) => (
<div
key={i}
className="pointer-events-none absolute rounded-full border"
style={{
width: 2 * r,
height: 2 * r,
left: `calc(50% - ${r}px)`,
top: `calc(50% - ${r}px)`,
borderWidth: RING_STROKE,
borderColor: "var(--cal-border-subtle)",
}}
/>
))}
{/* Central user icon */}
<div
className="from-default to-muted border-subtle absolute flex items-center justify-center rounded-full border bg-gradient-to-b shadow-sm"
style={{
width: LARGE.outer,
height: LARGE.outer,
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
}}>
<Icon
name="user"
className="text-emphasis opacity-70"
style={{ width: LARGE.icon, height: LARGE.icon }}
/>
</div>
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-cal text-emphasis text-2xl leading-none">
{t("welcome_to_calcom", { appName: APP_NAME })}
</h2>
<p className="text-default text-sm leading-normal">{t("personal_welcome_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
{features.map((feature) => (
<div key={feature} className="flex items-start gap-2">
<Icon name="check" className="text-muted mt-0.5 h-4 w-4 flex-shrink-0" />
<span className="text-default text-sm font-medium leading-tight">{t(feature)}</span>
</div>
))}
</div>
</div>
<div className="bg-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
<Button
color="minimal"
href="https://cal.com/docs"
target="_blank"
EndIcon="external-link"
className="pointer-events-none opacity-0">
{t("learn_more")}
</Button>
<Button color="primary" onClick={closeModal}>
{t("continue")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,8 @@
import { parseAsBoolean, useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { sessionStorage } from "@calcom/lib/webstorage";
const STORAGE_KEY = "showWelcomeToCalcomModal";
export function useWelcomeToCalcomModal() {
@@ -12,14 +14,12 @@ export function useWelcomeToCalcomModal() {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Check query param first
if (welcomeToCalcomModal) {
setIsOpen(true);
return;
}
// Check sessionStorage as fallback (for cases where we redirect through personal onboarding)
if (typeof window !== "undefined" && sessionStorage.getItem(STORAGE_KEY) === "true") {
if (sessionStorage.getItem(STORAGE_KEY) === "true") {
setIsOpen(true);
}
}, [welcomeToCalcomModal]);
@@ -29,9 +29,7 @@ export function useWelcomeToCalcomModal() {
// Remove the query param from URL
setWelcomeToCalcomModal(null);
// Also clear sessionStorage
if (typeof window !== "undefined") {
sessionStorage.removeItem(STORAGE_KEY);
}
sessionStorage.removeItem(STORAGE_KEY);
};
return {
@@ -40,12 +38,6 @@ export function useWelcomeToCalcomModal() {
};
}
/**
* Helper function to set the flag that triggers the welcome modal.
* Use this before redirecting to ensure the modal shows after navigation.
*/
export function setShowWelcomeToCalcomModalFlag() {
if (typeof window !== "undefined") {
sessionStorage.setItem(STORAGE_KEY, "true");
}
sessionStorage.setItem(STORAGE_KEY, "true");
}