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) => ( - ({timezoneList.getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")} + ({getTimezoneOffset(tz)}) {tz.replace(/_/g, " ")} ))} @@ -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"); }