Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+27
-1
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
[]
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user