[#154] added timezone component to change timezone of calendar (#207)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-10-14 15:25:28 +02:00
committed by GitHub
parent 26e69091d9
commit 770257c03b
14 changed files with 712 additions and 137 deletions
@@ -0,0 +1,257 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import CalendarApp from "../../../src/components/Calendar/Calendar";
import { updateSlotLabelVisibility } from "../../../src/components/Calendar/utils/calendarUtils";
import EventPreviewModal from "../../../src/components/Event/EventDisplayPreview";
import * as CalendarSlice from "../../../src/features/Calendars/CalendarSlice";
import * as calendarUtils from "../../../src/components/Calendar/utils/calendarUtils";
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
import { renderWithProviders } from "../../utils/Renderwithproviders";
import preview from "jest-preview";
describe("Calendar - Timezone Integration", () => {
const mockCalendarRef = { current: null };
const baseState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "testSid",
openpaasId: "user1",
},
},
calendars: {
list: {
"user1/cal1": {
id: "user1/cal1",
link: "/calendars/user1/cal1.json",
name: "Test Calendar",
description: "",
color: "#33B679",
owner: "user1",
ownerEmails: ["test@test.com"],
visibility: "public",
events: {},
},
},
timeZone: "America/New_York",
pending: false,
},
events: {
selectedEvent: null,
isEditMode: false,
editModeDialogOpen: false,
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it("renders TimezoneSelector in week view", async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
baseState
);
// Look for timezone selector button (should show offset)
await waitFor(() => {
expect(screen.getByText(/UTC/)).toBeInTheDocument();
});
});
it("dispatches setTimeZone action when timezone is changed", async () => {
const setTimeZoneSpy = jest.spyOn(CalendarSlice, "setTimeZone");
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
baseState
);
// Find and click timezone selector
await waitFor(() => {
const timezoneButton = screen.getByText(/UTC/i);
fireEvent.click(timezoneButton);
// Select a different timezone
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Tokyo" } });
});
const option = await screen.findByText(/Tokyo/i);
fireEvent.click(option);
expect(setTimeZoneSpy).toHaveBeenCalledWith("Asia/Tokyo");
});
});
describe("Calendar - Timezone Slot Label Visibility", () => {
it("hides slot labels within 15 minutes of current time", () => {
const currentTime = new Date("2025-01-15T14:30:00Z");
const timezone = "UTC";
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => true);
// 14:25 - within 15 minutes
const slot1425 = { text: "14:25" };
expect(updateSlotLabelVisibility(currentTime, slot1425, timezone)).toBe(
"timegrid-slot-label-hidden"
);
// 14:30 - exact match
const slot1430 = { text: "14:30" };
expect(updateSlotLabelVisibility(currentTime, slot1430, timezone)).toBe(
"timegrid-slot-label-hidden"
);
// 14:35 - within 15 minutes
const slot1435 = { text: "14:35" };
expect(updateSlotLabelVisibility(currentTime, slot1435, timezone)).toBe(
"timegrid-slot-label-hidden"
);
});
it("shows slot labels more than 15 minutes from current time", () => {
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => true);
const currentTime = new Date("2025-01-15T14:30:00");
const timezone = "UTC";
// 14:00 - 30 minutes before
const slot1400 = { text: "14:00" };
expect(updateSlotLabelVisibility(currentTime, slot1400, timezone)).toBe(
"fc-timegrid-slot-label"
);
// 15:00 - 30 minutes after
const slot1500 = { text: "15:00" };
expect(updateSlotLabelVisibility(currentTime, slot1500, timezone)).toBe(
"fc-timegrid-slot-label"
);
});
it("returns visible class when not in current week/day", () => {
jest
.spyOn(calendarUtils, "checkIfCurrentWeekOrDay")
.mockImplementation(() => false);
const currentTime = new Date("2025-01-15T14:30:00");
const timezone = "UTC";
const slot = { text: "14:30" };
// Should return visible class when not in current view
const result = updateSlotLabelVisibility(currentTime, slot, timezone);
expect(result).toBe("fc-timegrid-slot-label");
});
});
describe("EventDisplayPreview - Timezone Display", () => {
const mockOnClose = jest.fn();
const baseEvent: CalendarEvent = {
uid: "event1",
title: "Team Meeting",
start: new Date("2025-01-15T14:00:00Z"),
end: new Date("2025-01-15T15:00:00Z"),
calendarId: "user1/cal1",
allday: false,
};
const allDayEvent = {
...baseEvent,
allday: true,
start: new Date("2025-01-15"),
end: new Date("2025-01-16"),
};
const baseState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
sid: "testSid",
openpaasId: "user1",
},
},
calendars: {
list: {
"user1/cal1": {
id: "user1/cal1",
name: "Test Calendar",
color: "#33B679",
owner: "user1",
ownerEmails: ["test@test.com"],
visibility: "public",
events: {
event1: baseEvent,
allDayEvent,
},
},
},
timeZone: "America/New_York",
pending: false,
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it("does not show timezone offset for all-day events", async () => {
renderWithProviders(
<EventPreviewModal
open={true}
onClose={mockOnClose}
eventId="allDayEvent"
calId="user1/cal1"
event={allDayEvent}
/>,
baseState
);
await waitFor(() => {
const title = screen.getByText(/Team Meeting/i);
expect(title).toBeInTheDocument();
});
// Should not show UTC offset
expect(screen.queryByText(/UTC[+-]\d+/i)).not.toBeInTheDocument();
});
it("displays correct timezone offset for different timezones", async () => {
const timezones = [
{ tz: "America/New_York", expectedOffset: /UTC-[45]/ },
{ tz: "Europe/Paris", expectedOffset: /UTC\+[12]/ },
{ tz: "Asia/Tokyo", expectedOffset: /UTC\+9/ },
{ tz: "Australia/Sydney", expectedOffset: /UTC\+1[01]/ },
{ tz: "Asia/Kolkata", expectedOffset: /UTC\+5:30/ },
];
for (const { tz, expectedOffset } of timezones) {
const state = {
...baseState,
calendars: {
...baseState.calendars,
timeZone: tz,
},
};
renderWithProviders(
<EventPreviewModal
open={true}
eventId="event1"
calId="user1/cal1"
onClose={mockOnClose}
event={baseEvent}
/>,
state
);
await waitFor(() => {
const content = document.body.textContent || "";
expect(content).toMatch(expectedOffset);
});
}
});
});
@@ -0,0 +1,88 @@
import { screen, fireEvent, waitFor } from "@testing-library/react";
import { TimezoneSelector } from "../../../src/components/Calendar/TimezoneSelector";
import { renderWithProviders } from "../../utils/Renderwithproviders";
describe("TimezoneSelector", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it("renders with initial timezone value", () => {
renderWithProviders(
<TimezoneSelector value="America/New_York" onChange={mockOnChange} />
);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent("UTC-4"); // New York offset
});
it("opens popover when button is clicked", async () => {
renderWithProviders(
<TimezoneSelector value="Europe/Paris" onChange={mockOnChange} />
);
const button = screen.getByRole("button");
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
});
it("calls onChange when a new timezone is selected", async () => {
renderWithProviders(
<TimezoneSelector value="Europe/Paris" onChange={mockOnChange} />
);
const button = screen.getByRole("button");
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Los Angeles" } });
// Find and click the Los Angeles option
const option = await screen.findByText(/Los Angeles/i);
fireEvent.click(option);
expect(mockOnChange).toHaveBeenCalledWith("America/Los_Angeles");
});
it("closes popover after timezone selection", async () => {
renderWithProviders(
<TimezoneSelector value="Europe/Paris" onChange={mockOnChange} />
);
const button = screen.getByRole("button");
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
const autocomplete = screen.getByRole("combobox");
fireEvent.change(autocomplete, { target: { value: "Tokyo" } });
const option = await screen.findByText(/Tokyo/i);
fireEvent.click(option);
await waitFor(() => {
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
});
it("displays timezones with half-hour offsets correctly", () => {
renderWithProviders(
<TimezoneSelector value="Asia/Kolkata" onChange={mockOnChange} />
);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("UTC+5:30"); // India offset
});
});
+27 -1
View File
@@ -14,6 +14,7 @@
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17", "@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17", "@fullcalendar/list": "^6.1.17",
"@fullcalendar/moment-timezone": "^6.1.19",
"@fullcalendar/react": "^6.1.17", "@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17", "@fullcalendar/timegrid": "^6.1.17",
"@mui/icons-material": "^7.1.2", "@mui/icons-material": "^7.1.2",
@@ -26,6 +27,7 @@
"ical.js": "^2.2.0", "ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-calendar": "^6.0.0", "react-calendar": "^6.0.0",
@@ -2289,7 +2291,9 @@
} }
}, },
"node_modules/@fullcalendar/core": { "node_modules/@fullcalendar/core": {
"version": "6.1.18", "version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz",
"integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"preact": "~10.12.1" "preact": "~10.12.1"
@@ -2316,6 +2320,16 @@
"@fullcalendar/core": "~6.1.18" "@fullcalendar/core": "~6.1.18"
} }
}, },
"node_modules/@fullcalendar/moment-timezone": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/moment-timezone/-/moment-timezone-6.1.19.tgz",
"integrity": "sha512-6UOhMThdzDnh10/SPW5t5zmNq+betGebK3i7ytg2EHzlEb2EztfHJC5mbqEU2B2AoKNr2FUIonWuergYe7OVhA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19",
"moment-timezone": "^0.5.40"
}
},
"node_modules/@fullcalendar/react": { "node_modules/@fullcalendar/react": {
"version": "6.1.18", "version": "6.1.18",
"license": "MIT", "license": "MIT",
@@ -10843,6 +10857,18 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"dev": true, "dev": true,
+3
View File
@@ -9,6 +9,7 @@
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17", "@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17", "@fullcalendar/list": "^6.1.17",
"@fullcalendar/moment-timezone": "^6.1.19",
"@fullcalendar/react": "^6.1.17", "@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17", "@fullcalendar/timegrid": "^6.1.17",
"@mui/icons-material": "^7.1.2", "@mui/icons-material": "^7.1.2",
@@ -21,6 +22,8 @@
"ical.js": "^2.2.0", "ical.js": "^2.2.0",
"ky": "^1.8.1", "ky": "^1.8.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.5.48",
"moment": "^2.30.1",
"openid-client": "^6.5.3", "openid-client": "^6.5.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-calendar": "^6.0.0", "react-calendar": "^6.0.0",
+5
View File
@@ -133,3 +133,8 @@
border-radius 50% border-radius 50%
margin-left auto margin-left auto
margin-right auto margin-right auto
.weekSelector
display flex
flex-direction column
align-items center
+39 -3
View File
@@ -11,7 +11,10 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal"; import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes"; import { CalendarEvent } from "../../features/Events/EventsTypes";
import CalendarSelection from "./CalendarSelection"; import CalendarSelection from "./CalendarSelection";
import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice"; import {
getCalendarDetailAsync,
setTimeZone,
} from "../../features/Calendars/CalendarSlice";
import ImportAlert from "../../features/Events/ImportAlert"; import ImportAlert from "../../features/Events/ImportAlert";
import { import {
computeStartOfTheWeek, computeStartOfTheWeek,
@@ -36,6 +39,8 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { EditModeDialog } from "../Event/EditModeDialog"; import { EditModeDialog } from "../Event/EditModeDialog";
import { EventErrorHandler } from "../Error/EventErrorHandler"; import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar"; import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
import momentTimezonePlugin from "@fullcalendar/moment-timezone";
import { TimezoneSelector } from "./TimezoneSelector";
interface CalendarAppProps { interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>; calendarRef: React.RefObject<CalendarApi | null>;
@@ -70,6 +75,10 @@ export default function CalendarApp({
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) =>
Object.values(calendars[calId].events) Object.values(calendars[calId].events)
); );
const [currentView, setCurrentView] = useState("timeGridWeek");
const timezone = useAppSelector((state) => state.calendars.timeZone);
const fetchedRangesRef = useRef<Record<string, string>>({}); const fetchedRangesRef = useRef<Record<string, string>>({});
// Auto-select personal calendars when first loaded // Auto-select personal calendars when first loaded
@@ -335,15 +344,24 @@ export default function CalendarApp({
calendarRef.current = ref.getApi(); calendarRef.current = ref.getApi();
} }
}} }}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]} plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
]}
initialView="timeGridWeek" initialView="timeGridWeek"
firstDay={1} firstDay={1}
editable={true} editable={true}
selectable={true} selectable={true}
timeZone="local" timeZone={timezone}
height={"100%"} height={"100%"}
select={eventHandlers.handleDateSelect} select={eventHandlers.handleDateSelect}
nowIndicator nowIndicator
slotLabelClassNames={(arg) => [
updateSlotLabelVisibility(new Date(), arg, timezone),
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false} headerToolbar={false}
views={{ views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } }, timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
@@ -356,6 +374,23 @@ export default function CalendarApp({
)} )}
weekNumbers weekNumbers
weekNumberFormat={{ week: "long" }} weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
const showSelector =
currentView === "timeGridWeek" || currentView === "timeGridDay";
return (
<div className="weekSelector">
<div>{arg.text}</div>
{showSelector && (
<TimezoneSelector
value={timezone}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
)}
</div>
);
}}
slotDuration={"00:30:00"} slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"} slotLabelInterval={"01:00:00"}
scrollTime="12:00:00" scrollTime="12:00:00"
@@ -367,6 +402,7 @@ export default function CalendarApp({
hour12: false, hour12: false,
}} }}
datesSet={(arg) => { datesSet={(arg) => {
setCurrentView(arg.view.type);
// Get the current date from calendar API to ensure consistency // Get the current date from calendar API to ensure consistency
const calendarCurrentDate = const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start); calendarRef.current?.getDate() || new Date(arg.start);
+5 -1
View File
@@ -157,7 +157,8 @@ th.fc-col-header-cell.fc-day
position relative position relative
border 0 border 0
.fc .fc-timegrid-now-indicator-arrow::before .fc .fc-timegrid-now-indicator-arrow::before,
.now-time-label
content attr(data-time) content attr(data-time)
color #f67e35 color #f67e35
white-space nowrap white-space nowrap
@@ -169,6 +170,9 @@ th.fc-col-header-cell.fc-day
font-size 14px font-size 14px
letter-spacing 0.25px letter-spacing 0.25px
.timegrid-slot-label-hidden
opacity 0.2
.fc-day-today .fc-timegrid-now-indicator-container .fc-day-today .fc-timegrid-now-indicator-container
overflow unset overflow unset
@@ -0,0 +1,162 @@
import {
Autocomplete,
Button,
ListItem,
Popover,
TextField,
} from "@mui/material";
import { MouseEvent, useMemo, useState } from "react";
import { TIMEZONES } from "../../utils/timezone-data";
interface TimezoneSelectProps {
value: string;
onChange: (value: string) => void;
}
export function TimezoneSelector({ value, onChange }: TimezoneSelectProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
return { zones, browserTz, getTimezoneOffset };
}, []);
const options = useMemo(() => {
return timezoneList.zones.map((tz) => ({
value: tz,
label: tz.replace(/_/g, " "),
offset: timezoneList.getTimezoneOffset(tz),
}));
}, [timezoneList]);
const selectedOption =
options.find((opt) => opt.value === value) || options[0];
const handleOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<>
<Button
variant="text"
size="small"
onClick={handleOpen}
sx={{
textTransform: "none",
minWidth: "auto",
padding: "2px 4px",
margin: 0,
lineHeight: 1.2,
}}
>
{selectedOption ? selectedOption.offset : "Select Timezone"}
</Button>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: "auto", p: 1 },
},
}}
>
<TimeZoneSearch
selectedOption={selectedOption}
onChange={onChange}
handleClose={handleClose}
options={options}
/>
</Popover>
</>
);
}
function TimeZoneSearch({
selectedOption,
onChange,
handleClose,
options,
}: {
selectedOption: { value: string; label: string; offset: string };
onChange: (value: string) => void;
handleClose: () => void;
options: { value: string; label: string; offset: string }[];
}) {
return (
<Autocomplete
autoFocus
value={selectedOption}
onChange={(event, newValue) => {
if (newValue) {
onChange(newValue.value);
handleClose(); // close after selection
}
}}
options={options}
getOptionLabel={(option) => `${option.offset} ${option.label}`}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
size="small"
InputProps={{
...params.InputProps,
style: {
fontSize: "10px",
padding: "2px 4px",
},
}}
/>
)}
disableClearable
renderValue={(value) => <div>{value.offset}</div>}
/>
);
}
export function resolveTimezone(tzName: string): string {
if (TIMEZONES.zones[tzName]) {
return tzName;
}
if (TIMEZONES.aliases[tzName]) {
return TIMEZONES.aliases[tzName].aliasTo;
}
return tzName;
}
export function getTimezoneOffset(tzName: string): string {
const resolvedTz = resolveTimezone(tzName);
const tzData = TIMEZONES.zones[resolvedTz];
if (!tzData) return "";
const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/);
if (!icsMatch) return "";
const offset = icsMatch[1];
const hours = parseInt(offset.slice(0, 3));
const minutes = parseInt(offset.slice(3));
if (minutes === 0) {
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
}
return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`;
}
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { CalendarApi } from "@fullcalendar/core"; import { CalendarApi, NowIndicatorContentArg } from "@fullcalendar/core";
import { updateSlotLabelVisibility } from "../utils/calendarUtils"; import { updateSlotLabelVisibility } from "../utils/calendarUtils";
import { createMouseHandlers } from "./mouseHandlers"; import { createMouseHandlers } from "./mouseHandlers";
import { userAttendee } from "../../../features/User/userDataTypes"; import { userAttendee } from "../../../features/User/userDataTypes";
@@ -30,6 +30,25 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
errorHandler, errorHandler,
} = props; } = props;
const handleNowIndicatorContent = (arg: NowIndicatorContentArg) => {
if (arg.isAxis) {
return React.createElement(
"div",
{ style: { display: "flex", alignItems: "center" } },
React.createElement(
"div",
{ className: "now-time-label" },
new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: arg.view.dateEnv.timeZone,
})
)
);
}
};
const handleDayHeaderDidMount = (arg: any) => { const handleDayHeaderDidMount = (arg: any) => {
if (arg.view.type === "timeGridWeek") { if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el; const headerEl = arg.el;
@@ -61,54 +80,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
}; };
const handleViewDidMount = (arg: any) => { const handleViewDidMount = (arg: any) => {
const updateNowIndicator = () => {
const nowIndicatorArrow = document.querySelector(
".fc-timegrid-now-indicator-arrow"
) as HTMLElement;
if (nowIndicatorArrow) {
const now = new Date();
const timeString = now.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
nowIndicatorArrow.setAttribute("data-time", timeString);
updateSlotLabelVisibility(now);
}
};
updateNowIndicator();
const timeInterval = setInterval(updateNowIndicator, 60000);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (
element.classList?.contains("fc-timegrid-now-indicator-arrow") ||
element.querySelector?.(".fc-timegrid-now-indicator-arrow") ||
element.classList?.contains("fc-timegrid-slot-label") ||
element.querySelector?.(".fc-timegrid-slot-label")
) {
setTimeout(() => {
updateNowIndicator();
updateSlotLabelVisibility(new Date());
}, 10);
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
(arg.el as any).__timeInterval = timeInterval;
(arg.el as any).__timeObserver = observer;
if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") { if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") {
const calendarEl = document.querySelector(".fc") as HTMLElement; const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) { if (calendarEl) {
@@ -254,6 +225,7 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
}; };
return { return {
handleNowIndicatorContent,
handleDayHeaderDidMount, handleDayHeaderDidMount,
handleDayHeaderWillUnmount, handleDayHeaderWillUnmount,
handleViewDidMount, handleViewDidMount,
@@ -27,5 +27,9 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [ handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [
props.calendars, props.calendars,
]), ]),
handleNowIndicatorContent: useCallback(
viewHandlers.handleNowIndicatorContent,
[]
),
}; };
}; };
+25 -27
View File
@@ -2,45 +2,43 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes";
import { Calendars } from "../../../features/Calendars/CalendarTypes"; import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice"; import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice";
import { SlotLabelContentArg } from "@fullcalendar/core";
import moment from "moment-timezone";
export const updateSlotLabelVisibility = (currentTime: Date) => { export const updateSlotLabelVisibility = (
const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label"); currentTime: Date,
slotLabel: SlotLabelContentArg,
timezone: string
) => {
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay(); const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
if (!isCurrentWeekOrDay) { if (!isCurrentWeekOrDay) {
slotLabels.forEach((label) => { return "fc-timegrid-slot-label";
const labelElement = label as HTMLElement;
labelElement.style.opacity = "1";
});
return;
} }
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes(); const current = moment.tz(currentTime, timezone);
const currentMinutes = current.hours() * 60 + current.minutes();
const timeText = slotLabel?.text?.trim();
slotLabels.forEach((label) => { if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
const labelElement = label as HTMLElement; const [hours, minutes] = timeText.split(":").map(Number);
const timeText = labelElement.textContent?.trim(); const labelMinutes = hours * 60 + minutes;
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) { let timeDiff = Math.abs(currentMinutes - labelMinutes);
const [hours, minutes] = timeText.split(":").map(Number);
const labelMinutes = hours * 60 + minutes;
let timeDiff = Math.abs(currentMinutes - labelMinutes); if (timeDiff > 12 * 60) {
timeDiff = 24 * 60 - timeDiff;
if (timeDiff > 12 * 60) {
timeDiff = 24 * 60 - timeDiff;
}
if (timeDiff <= 15) {
labelElement.style.opacity = "0.2";
} else {
labelElement.style.opacity = "1";
}
} }
});
if (timeDiff <= 15) {
return "timegrid-slot-label-hidden";
}
}
return "fc-timegrid-slot-label";
}; };
const checkIfCurrentWeekOrDay = (): boolean => { export const checkIfCurrentWeekOrDay = (): boolean => {
const todayColumn = document.querySelector(".fc-day-today"); const todayColumn = document.querySelector(".fc-day-today");
if (!todayColumn) { if (!todayColumn) {
+19 -5
View File
@@ -30,6 +30,7 @@ import {
} from "../../features/Calendars/CalendarSlice"; } from "../../features/Calendars/CalendarSlice";
import { dlEvent } from "../../features/Events/EventApi"; import { dlEvent } from "../../features/Events/EventApi";
import EventDisplayModal from "../../features/Events/EventDisplay"; import EventDisplayModal from "../../features/Events/EventDisplay";
import { getTimezoneOffset } from "../Calendar/TimezoneSelector";
import { ResponsiveDialog } from "../Dialog"; import { ResponsiveDialog } from "../Dialog";
import { EditModeDialog } from "./EditModeDialog"; import { EditModeDialog } from "./EditModeDialog";
import EventDuplication from "./EventDuplicate"; import EventDuplication from "./EventDuplicate";
@@ -51,6 +52,9 @@ export default function EventPreviewModal({
}) { }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars); const calendars = useAppSelector((state) => state.calendars);
const timezone =
useAppSelector((state) => state.calendars.timeZone) ??
Intl.DateTimeFormat().resolvedOptions().timeZone;
const calendarList = Object.values( const calendarList = Object.values(
useAppSelector((state) => state.calendars.list) useAppSelector((state) => state.calendars.list)
); );
@@ -234,10 +238,10 @@ export default function EventPreviewModal({
{event.title} {event.title}
</Typography> </Typography>
<Typography variant="body2" color="textSecondary" gutterBottom> <Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start, event.allday)} {formatDate(event.start, timezone, event.allday)}
{event.end && {event.end &&
formatEnd(event.start, event.end, event.allday) && formatEnd(event.start, event.end, timezone, event.allday) &&
` ${formatEnd(event.start, event.end, event.allday)}`} ` ${formatEnd(event.start, event.end, timezone, event.allday)} ${!event.allday && getTimezoneOffset(timezone)}`}
</Typography> </Typography>
</> </>
) )
@@ -511,13 +515,14 @@ export default function EventPreviewModal({
); );
} }
function formatDate(date: Date | string, allday?: boolean) { function formatDate(date: Date | string, timeZone?: string, allday?: boolean) {
if (allday) { if (allday) {
return new Date(date).toLocaleDateString(undefined, { return new Date(date).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "long", month: "long",
weekday: "long", weekday: "long",
day: "numeric", day: "numeric",
timeZone,
}); });
} else { } else {
return new Date(date).toLocaleString(undefined, { return new Date(date).toLocaleString(undefined, {
@@ -527,11 +532,17 @@ function formatDate(date: Date | string, allday?: boolean) {
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone,
}); });
} }
} }
function formatEnd(start: Date | string, end: Date | string, allday?: boolean) { function formatEnd(
start: Date | string,
end: Date | string,
timeZone?: string,
allday?: boolean
) {
const startDate = new Date(start); const startDate = new Date(start);
const endDate = new Date(end); const endDate = new Date(end);
@@ -547,12 +558,14 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
timeZone,
}); });
} else { } else {
if (sameDay) { if (sameDay) {
return endDate.toLocaleTimeString(undefined, { return endDate.toLocaleTimeString(undefined, {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone,
}); });
} }
return endDate.toLocaleString(undefined, { return endDate.toLocaleString(undefined, {
@@ -561,6 +574,7 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
timeZone,
}); });
} }
} }
+10
View File
@@ -380,6 +380,12 @@ const CalendarSlice = createSlice({
list: {} as Record<string, Calendars>, list: {} as Record<string, Calendars>,
templist: {} as Record<string, Calendars>, templist: {} as Record<string, Calendars>,
pending: false, pending: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
} as {
list: Record<string, Calendars>;
templist: Record<string, Calendars>;
pending: boolean;
timeZone: string;
}, },
reducers: { reducers: {
createCalendar: (state, action: PayloadAction<Record<string, string>>) => { createCalendar: (state, action: PayloadAction<Record<string, string>>) => {
@@ -423,6 +429,9 @@ const CalendarSlice = createSlice({
const { calId, event } = action.payload; const { calId, event } = action.payload;
state.list[calId].events[event.uid] = event; state.list[calId].events[event.uid] = event;
}, },
setTimeZone: (state, action: PayloadAction<string>) => {
state.timeZone = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -689,5 +698,6 @@ export const {
createCalendar, createCalendar,
updateEventLocal, updateEventLocal,
removeTempCal, removeTempCal,
setTimeZone,
} = CalendarSlice.actions; } = CalendarSlice.actions;
export default CalendarSlice.reducer; export default CalendarSlice.reducer;
+47 -51
View File
@@ -44,6 +44,10 @@ import {
generateMeetingLink, generateMeetingLink,
addVideoConferenceToDescription, addVideoConferenceToDescription,
} from "../../utils/videoConferenceUtils"; } from "../../utils/videoConferenceUtils";
import {
getTimezoneOffset,
resolveTimezone,
} from "../../components/Calendar/TimezoneSelector";
// Helper component for field with label // Helper component for field with label
const FieldWithLabel = React.memo( const FieldWithLabel = React.memo(
@@ -135,44 +139,17 @@ function EventPopover({
selectPersonnalCalendars selectPersonnalCalendars
); );
// Helper function to resolve timezone aliases
const resolveTimezone = (tzName: string): string => {
if (TIMEZONES.zones[tzName]) {
return tzName;
}
if (TIMEZONES.aliases[tzName]) {
return TIMEZONES.aliases[tzName].aliasTo;
}
return tzName;
};
const timezoneList = useMemo(() => { const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort(); const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone( const browserTz = resolveTimezone(
Intl.DateTimeFormat().resolvedOptions().timeZone Intl.DateTimeFormat().resolvedOptions().timeZone
); );
const getTimezoneOffset = (tzName: string): string => { return { zones, browserTz };
const resolvedTz = resolveTimezone(tzName);
const tzData = TIMEZONES.zones[resolvedTz];
if (!tzData) return "";
const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/);
if (!icsMatch) return "";
const offset = icsMatch[1];
const hours = parseInt(offset.slice(0, 3));
const minutes = parseInt(offset.slice(3));
if (minutes === 0) {
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
}
return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`;
};
return { zones, browserTz, getTimezoneOffset };
}, []); }, []);
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showDescription, setShowDescription] = useState( const [showDescription, setShowDescription] = useState(
event?.description ? true : false event?.description ? true : false
@@ -206,7 +183,7 @@ function EventPopover({
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
const [important, setImportant] = useState(false); const [important, setImportant] = useState(false);
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState(
event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone
); );
const [hasVideoConference, setHasVideoConference] = useState( const [hasVideoConference, setHasVideoConference] = useState(
event?.x_openpass_videoconference ? true : false event?.x_openpass_videoconference ? true : false
@@ -241,15 +218,19 @@ function EventPopover({
setEventClass("PUBLIC"); setEventClass("PUBLIC");
setBusy("OPAQUE"); setBusy("OPAQUE");
setImportant(false); setImportant(false);
setTimezone(timezoneList.browserTz); setTimezone(calendarTimezone);
setHasVideoConference(false); setHasVideoConference(false);
setMeetingLink(null); setMeetingLink(null);
}, [timezoneList.browserTz]); }, [calendarTimezone]);
useEffect(() => { useEffect(() => {
if (selectedRange) { if (selectedRange) {
setStart(selectedRange ? formatLocalDateTime(selectedRange.start) : ""); setStart(
setEnd(selectedRange ? formatLocalDateTime(selectedRange.end) : ""); selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : ""
);
setEnd(
selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : ""
);
} }
}, [selectedRange]); }, [selectedRange]);
@@ -283,9 +264,7 @@ function EventPopover({
setEventClass(event.class ?? "PUBLIC"); setEventClass(event.class ?? "PUBLIC");
setBusy(event.transp ?? "OPAQUE"); setBusy(event.transp ?? "OPAQUE");
setTimezone( setTimezone(
event.timezone event.timezone ? resolveTimezone(event.timezone) : calendarTimezone
? resolveTimezone(event.timezone)
: timezoneList.browserTz
); );
setHasVideoConference(event.x_openpass_videoconference ? true : false); setHasVideoConference(event.x_openpass_videoconference ? true : false);
setMeetingLink(event.x_openpass_videoconference || null); setMeetingLink(event.x_openpass_videoconference || null);
@@ -305,7 +284,7 @@ function EventPopover({
} }
} }
} }
}, [event, organizer?.cal_address, timezoneList.browserTz]); }, [event, organizer?.cal_address, calendarTimezone]);
// Reset state when creating new event (event is undefined) // Reset state when creating new event (event is undefined)
useEffect(() => { useEffect(() => {
@@ -332,7 +311,7 @@ function EventPopover({
setMeetingLink(null); setMeetingLink(null);
} }
isInitializedRef.current = true; isInitializedRef.current = true;
}, [event, timezoneList.browserTz]); }, [event, calendarTimezone]);
const handleAddVideoConference = () => { const handleAddVideoConference = () => {
const newMeetingLink = generateMeetingLink(); const newMeetingLink = generateMeetingLink();
@@ -513,10 +492,16 @@ function EventPopover({
onChange={(e) => { onChange={(e) => {
const newStart = e.target.value; const newStart = e.target.value;
setStart(newStart); setStart(newStart);
// Update selectedRange for visual feedback
const startISO = formatLocalDateTime(
new Date(newStart),
timezone
);
const newRange = { const newRange = {
...selectedRange, ...selectedRange,
start: new Date(newStart), start: new Date(startISO),
startStr: newStart, startStr: startISO,
allDay: allday, allDay: allday,
}; };
setSelectedRange(newRange); setSelectedRange(newRange);
@@ -541,10 +526,13 @@ function EventPopover({
onChange={(e) => { onChange={(e) => {
const newEnd = e.target.value; const newEnd = e.target.value;
setEnd(newEnd); setEnd(newEnd);
// Update selectedRange for visual feedback
const endISO = formatLocalDateTime(new Date(newEnd), timezone);
const newRange = { const newRange = {
...selectedRange, ...selectedRange,
end: new Date(newEnd), end: new Date(endISO),
endStr: newEnd, endStr: endISO,
allDay: allday, allDay: allday,
}; };
setSelectedRange(newRange); setSelectedRange(newRange);
@@ -578,7 +566,7 @@ function EventPopover({
setAllDay(!allday); setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) { if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1); endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate)); setEnd(formatLocalDateTime(endDate, timezone));
} }
const newRange = { const newRange = {
@@ -638,7 +626,7 @@ function EventPopover({
> >
{timezoneList.zones.map((tz) => ( {timezoneList.zones.map((tz) => (
<MenuItem key={tz} value={tz}> <MenuItem key={tz} value={tz}>
({timezoneList.getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")} ({getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@@ -814,9 +802,17 @@ function EventPopover({
export default EventPopover; export default EventPopover;
export function formatLocalDateTime(date: Date): string { export function formatLocalDateTime(date: Date, timeZone?: string) {
const pad = (n: number) => n.toString().padStart(2, "0"); const formatter = new Intl.DateTimeFormat("en-CA", {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( year: "numeric",
date.getDate() month: "2-digit",
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
const formatted = formatter.format(date);
return formatted.replace(", ", "T");
} }