diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx
new file mode 100644
index 0000000..9d00f95
--- /dev/null
+++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx
@@ -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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ 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(
+ ,
+ state
+ );
+
+ await waitFor(() => {
+ const content = document.body.textContent || "";
+ expect(content).toMatch(expectedOffset);
+ });
+ }
+ });
+});
diff --git a/__test__/features/Calendars/TimezoneSelector.test.tsx b/__test__/features/Calendars/TimezoneSelector.test.tsx
new file mode 100644
index 0000000..1814d91
--- /dev/null
+++ b/__test__/features/Calendars/TimezoneSelector.test.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ const button = screen.getByRole("button");
+ expect(button).toHaveTextContent("UTC+5:30"); // India offset
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index dd37470..9d8a2ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
+ "@fullcalendar/moment-timezone": "^6.1.19",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@mui/icons-material": "^7.1.2",
@@ -26,6 +27,7 @@
"ical.js": "^2.2.0",
"ky": "^1.8.1",
"moment": "^2.30.1",
+ "moment-timezone": "^0.5.48",
"openid-client": "^6.5.3",
"react": "^19.1.0",
"react-calendar": "^6.0.0",
@@ -2289,7 +2291,9 @@
}
},
"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",
"dependencies": {
"preact": "~10.12.1"
@@ -2316,6 +2320,16 @@
"@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": {
"version": "6.1.18",
"license": "MIT",
@@ -10843,6 +10857,18 @@
"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": {
"version": "2.0.1",
"dev": true,
diff --git a/package.json b/package.json
index 271b813..9f384c3 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
+ "@fullcalendar/moment-timezone": "^6.1.19",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@mui/icons-material": "^7.1.2",
@@ -21,6 +22,8 @@
"ical.js": "^2.2.0",
"ky": "^1.8.1",
"moment": "^2.30.1",
+ "moment-timezone": "^0.5.48",
+ "moment": "^2.30.1",
"openid-client": "^6.5.3",
"react": "^19.1.0",
"react-calendar": "^6.0.0",
diff --git a/src/components/Calendar/Calendar.styl b/src/components/Calendar/Calendar.styl
index 3fcd106..537e028 100644
--- a/src/components/Calendar/Calendar.styl
+++ b/src/components/Calendar/Calendar.styl
@@ -133,3 +133,8 @@
border-radius 50%
margin-left auto
margin-right auto
+
+.weekSelector
+ display flex
+ flex-direction column
+ align-items center
\ No newline at end of file
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index 4b38c79..3ab0b05 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -11,7 +11,10 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import CalendarSelection from "./CalendarSelection";
-import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
+import {
+ getCalendarDetailAsync,
+ setTimeZone,
+} from "../../features/Calendars/CalendarSlice";
import ImportAlert from "../../features/Events/ImportAlert";
import {
computeStartOfTheWeek,
@@ -36,6 +39,8 @@ import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { EditModeDialog } from "../Event/EditModeDialog";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
+import momentTimezonePlugin from "@fullcalendar/moment-timezone";
+import { TimezoneSelector } from "./TimezoneSelector";
interface CalendarAppProps {
calendarRef: React.RefObject;
@@ -70,6 +75,10 @@ export default function CalendarApp({
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) =>
Object.values(calendars[calId].events)
);
+
+ const [currentView, setCurrentView] = useState("timeGridWeek");
+ const timezone = useAppSelector((state) => state.calendars.timeZone);
+
const fetchedRangesRef = useRef>({});
// Auto-select personal calendars when first loaded
@@ -335,15 +344,24 @@ export default function CalendarApp({
calendarRef.current = ref.getApi();
}
}}
- plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
+ plugins={[
+ dayGridPlugin,
+ timeGridPlugin,
+ interactionPlugin,
+ momentTimezonePlugin,
+ ]}
initialView="timeGridWeek"
firstDay={1}
editable={true}
selectable={true}
- timeZone="local"
+ timeZone={timezone}
height={"100%"}
select={eventHandlers.handleDateSelect}
nowIndicator
+ slotLabelClassNames={(arg) => [
+ updateSlotLabelVisibility(new Date(), arg, timezone),
+ ]}
+ nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
@@ -356,6 +374,23 @@ export default function CalendarApp({
)}
weekNumbers
weekNumberFormat={{ week: "long" }}
+ weekNumberContent={(arg) => {
+ const showSelector =
+ currentView === "timeGridWeek" || currentView === "timeGridDay";
+ return (
+
+
{arg.text}
+ {showSelector && (
+
+ dispatch(setTimeZone(newTimezone))
+ }
+ />
+ )}
+
+ );
+ }}
slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"}
scrollTime="12:00:00"
@@ -367,6 +402,7 @@ export default function CalendarApp({
hour12: false,
}}
datesSet={(arg) => {
+ setCurrentView(arg.view.type);
// Get the current date from calendar API to ensure consistency
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
diff --git a/src/components/Calendar/CustomCalendar.styl b/src/components/Calendar/CustomCalendar.styl
index 45db72a..0a53be7 100644
--- a/src/components/Calendar/CustomCalendar.styl
+++ b/src/components/Calendar/CustomCalendar.styl
@@ -157,7 +157,8 @@ th.fc-col-header-cell.fc-day
position relative
border 0
-.fc .fc-timegrid-now-indicator-arrow::before
+.fc .fc-timegrid-now-indicator-arrow::before,
+.now-time-label
content attr(data-time)
color #f67e35
white-space nowrap
@@ -169,6 +170,9 @@ th.fc-col-header-cell.fc-day
font-size 14px
letter-spacing 0.25px
+.timegrid-slot-label-hidden
+ opacity 0.2
+
.fc-day-today .fc-timegrid-now-indicator-container
overflow unset
diff --git a/src/components/Calendar/TimezoneSelector.tsx b/src/components/Calendar/TimezoneSelector.tsx
new file mode 100644
index 0000000..d17c0c2
--- /dev/null
+++ b/src/components/Calendar/TimezoneSelector.tsx
@@ -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(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) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const open = Boolean(anchorEl);
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+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 (
+ {
+ if (newValue) {
+ onChange(newValue.value);
+ handleClose(); // close after selection
+ }
+ }}
+ options={options}
+ getOptionLabel={(option) => `${option.offset} ${option.label}`}
+ renderInput={(params) => (
+
+ )}
+ disableClearable
+ renderValue={(value) => {value.offset}
}
+ />
+ );
+}
+
+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")}`;
+}
diff --git a/src/components/Calendar/handlers/viewHandlers.ts b/src/components/Calendar/handlers/viewHandlers.ts
index 0f22646..544c8ca 100644
--- a/src/components/Calendar/handlers/viewHandlers.ts
+++ b/src/components/Calendar/handlers/viewHandlers.ts
@@ -1,5 +1,5 @@
import React from "react";
-import { CalendarApi } from "@fullcalendar/core";
+import { CalendarApi, NowIndicatorContentArg } from "@fullcalendar/core";
import { updateSlotLabelVisibility } from "../utils/calendarUtils";
import { createMouseHandlers } from "./mouseHandlers";
import { userAttendee } from "../../../features/User/userDataTypes";
@@ -30,6 +30,25 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
errorHandler,
} = 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) => {
if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el;
@@ -61,54 +80,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
};
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") {
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) {
@@ -254,6 +225,7 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
};
return {
+ handleNowIndicatorContent,
handleDayHeaderDidMount,
handleDayHeaderWillUnmount,
handleViewDidMount,
diff --git a/src/components/Calendar/hooks/useCalendarViewHandlers.ts b/src/components/Calendar/hooks/useCalendarViewHandlers.ts
index 77df655..25d63c3 100644
--- a/src/components/Calendar/hooks/useCalendarViewHandlers.ts
+++ b/src/components/Calendar/hooks/useCalendarViewHandlers.ts
@@ -27,5 +27,9 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [
props.calendars,
]),
+ handleNowIndicatorContent: useCallback(
+ viewHandlers.handleNowIndicatorContent,
+ []
+ ),
};
};
diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts
index a9270ac..6992b8e 100644
--- a/src/components/Calendar/utils/calendarUtils.ts
+++ b/src/components/Calendar/utils/calendarUtils.ts
@@ -2,45 +2,43 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice";
+import { SlotLabelContentArg } from "@fullcalendar/core";
+import moment from "moment-timezone";
-export const updateSlotLabelVisibility = (currentTime: Date) => {
- const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label");
+export const updateSlotLabelVisibility = (
+ currentTime: Date,
+ slotLabel: SlotLabelContentArg,
+ timezone: string
+) => {
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
if (!isCurrentWeekOrDay) {
- slotLabels.forEach((label) => {
- const labelElement = label as HTMLElement;
- labelElement.style.opacity = "1";
- });
- return;
+ return "fc-timegrid-slot-label";
}
- 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) => {
- const labelElement = label as HTMLElement;
- const timeText = labelElement.textContent?.trim();
+ if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
+ const [hours, minutes] = timeText.split(":").map(Number);
+ const labelMinutes = hours * 60 + minutes;
- if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
- const [hours, minutes] = timeText.split(":").map(Number);
- const labelMinutes = hours * 60 + minutes;
+ let timeDiff = Math.abs(currentMinutes - labelMinutes);
- let timeDiff = Math.abs(currentMinutes - labelMinutes);
-
- if (timeDiff > 12 * 60) {
- timeDiff = 24 * 60 - timeDiff;
- }
-
- if (timeDiff <= 15) {
- labelElement.style.opacity = "0.2";
- } else {
- labelElement.style.opacity = "1";
- }
+ if (timeDiff > 12 * 60) {
+ timeDiff = 24 * 60 - timeDiff;
}
- });
+
+ 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");
if (!todayColumn) {
diff --git a/src/components/Event/EventDisplayPreview.tsx b/src/components/Event/EventDisplayPreview.tsx
index e0474ee..800fccc 100644
--- a/src/components/Event/EventDisplayPreview.tsx
+++ b/src/components/Event/EventDisplayPreview.tsx
@@ -30,6 +30,7 @@ import {
} from "../../features/Calendars/CalendarSlice";
import { dlEvent } from "../../features/Events/EventApi";
import EventDisplayModal from "../../features/Events/EventDisplay";
+import { getTimezoneOffset } from "../Calendar/TimezoneSelector";
import { ResponsiveDialog } from "../Dialog";
import { EditModeDialog } from "./EditModeDialog";
import EventDuplication from "./EventDuplicate";
@@ -51,6 +52,9 @@ export default function EventPreviewModal({
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
+ const timezone =
+ useAppSelector((state) => state.calendars.timeZone) ??
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
const calendarList = Object.values(
useAppSelector((state) => state.calendars.list)
);
@@ -234,10 +238,10 @@ export default function EventPreviewModal({
{event.title}
- {formatDate(event.start, event.allday)}
+ {formatDate(event.start, timezone, event.allday)}
{event.end &&
- formatEnd(event.start, event.end, event.allday) &&
- ` – ${formatEnd(event.start, event.end, event.allday)}`}
+ formatEnd(event.start, event.end, timezone, event.allday) &&
+ ` – ${formatEnd(event.start, event.end, timezone, event.allday)} ${!event.allday && getTimezoneOffset(timezone)}`}
>
)
@@ -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) {
return new Date(date).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
+ timeZone,
});
} else {
return new Date(date).toLocaleString(undefined, {
@@ -527,11 +532,17 @@ function formatDate(date: Date | string, allday?: boolean) {
day: "numeric",
hour: "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 endDate = new Date(end);
@@ -547,12 +558,14 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
year: "numeric",
month: "short",
day: "numeric",
+ timeZone,
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
+ timeZone,
});
}
return endDate.toLocaleString(undefined, {
@@ -561,6 +574,7 @@ function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
+ timeZone,
});
}
}
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts
index 5eefe8d..ff52254 100644
--- a/src/features/Calendars/CalendarSlice.ts
+++ b/src/features/Calendars/CalendarSlice.ts
@@ -380,6 +380,12 @@ const CalendarSlice = createSlice({
list: {} as Record,
templist: {} as Record,
pending: false,
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ } as {
+ list: Record;
+ templist: Record;
+ pending: boolean;
+ timeZone: string;
},
reducers: {
createCalendar: (state, action: PayloadAction>) => {
@@ -423,6 +429,9 @@ const CalendarSlice = createSlice({
const { calId, event } = action.payload;
state.list[calId].events[event.uid] = event;
},
+ setTimeZone: (state, action: PayloadAction) => {
+ state.timeZone = action.payload;
+ },
},
extraReducers: (builder) => {
builder
@@ -689,5 +698,6 @@ export const {
createCalendar,
updateEventLocal,
removeTempCal,
+ setTimeZone,
} = CalendarSlice.actions;
export default CalendarSlice.reducer;
diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx
index 09e1f00..2cb1458 100644
--- a/src/features/Events/EventModal.tsx
+++ b/src/features/Events/EventModal.tsx
@@ -44,6 +44,10 @@ import {
generateMeetingLink,
addVideoConferenceToDescription,
} from "../../utils/videoConferenceUtils";
+import {
+ getTimezoneOffset,
+ resolveTimezone,
+} from "../../components/Calendar/TimezoneSelector";
// Helper component for field with label
const FieldWithLabel = React.memo(
@@ -135,44 +139,17 @@ function EventPopover({
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 zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(
Intl.DateTimeFormat().resolvedOptions().timeZone
);
- const 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")}`;
- };
-
- return { zones, browserTz, getTimezoneOffset };
+ return { zones, browserTz };
}, []);
+ const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
+
const [showMore, setShowMore] = useState(false);
const [showDescription, setShowDescription] = useState(
event?.description ? true : false
@@ -206,7 +183,7 @@ function EventPopover({
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
const [important, setImportant] = useState(false);
const [timezone, setTimezone] = useState(
- event?.timezone ? resolveTimezone(event.timezone) : timezoneList.browserTz
+ event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone
);
const [hasVideoConference, setHasVideoConference] = useState(
event?.x_openpass_videoconference ? true : false
@@ -241,15 +218,19 @@ function EventPopover({
setEventClass("PUBLIC");
setBusy("OPAQUE");
setImportant(false);
- setTimezone(timezoneList.browserTz);
+ setTimezone(calendarTimezone);
setHasVideoConference(false);
setMeetingLink(null);
- }, [timezoneList.browserTz]);
+ }, [calendarTimezone]);
useEffect(() => {
if (selectedRange) {
- setStart(selectedRange ? formatLocalDateTime(selectedRange.start) : "");
- setEnd(selectedRange ? formatLocalDateTime(selectedRange.end) : "");
+ setStart(
+ selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : ""
+ );
+ setEnd(
+ selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : ""
+ );
}
}, [selectedRange]);
@@ -283,9 +264,7 @@ function EventPopover({
setEventClass(event.class ?? "PUBLIC");
setBusy(event.transp ?? "OPAQUE");
setTimezone(
- event.timezone
- ? resolveTimezone(event.timezone)
- : timezoneList.browserTz
+ event.timezone ? resolveTimezone(event.timezone) : calendarTimezone
);
setHasVideoConference(event.x_openpass_videoconference ? true : false);
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)
useEffect(() => {
@@ -332,7 +311,7 @@ function EventPopover({
setMeetingLink(null);
}
isInitializedRef.current = true;
- }, [event, timezoneList.browserTz]);
+ }, [event, calendarTimezone]);
const handleAddVideoConference = () => {
const newMeetingLink = generateMeetingLink();
@@ -513,10 +492,16 @@ function EventPopover({
onChange={(e) => {
const newStart = e.target.value;
setStart(newStart);
+
+ // Update selectedRange for visual feedback
+ const startISO = formatLocalDateTime(
+ new Date(newStart),
+ timezone
+ );
const newRange = {
...selectedRange,
- start: new Date(newStart),
- startStr: newStart,
+ start: new Date(startISO),
+ startStr: startISO,
allDay: allday,
};
setSelectedRange(newRange);
@@ -541,10 +526,13 @@ function EventPopover({
onChange={(e) => {
const newEnd = e.target.value;
setEnd(newEnd);
+
+ // Update selectedRange for visual feedback
+ const endISO = formatLocalDateTime(new Date(newEnd), timezone);
const newRange = {
...selectedRange,
- end: new Date(newEnd),
- endStr: newEnd,
+ end: new Date(endISO),
+ endStr: endISO,
allDay: allday,
};
setSelectedRange(newRange);
@@ -578,7 +566,7 @@ function EventPopover({
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
- setEnd(formatLocalDateTime(endDate));
+ setEnd(formatLocalDateTime(endDate, timezone));
}
const newRange = {
@@ -638,7 +626,7 @@ function EventPopover({
>
{timezoneList.zones.map((tz) => (
))}
@@ -814,9 +802,17 @@ function EventPopover({
export default EventPopover;
-export function formatLocalDateTime(date: Date): string {
- const pad = (n: number) => n.toString().padStart(2, "0");
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
- date.getDate()
- )}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+export function formatLocalDateTime(date: Date, timeZone?: string) {
+ const formatter = new Intl.DateTimeFormat("en-CA", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ timeZone,
+ });
+
+ const formatted = formatter.format(date);
+ return formatted.replace(", ", "T");
}