diff --git a/__test__/components/Event/utils/dateTimeHelpers.test.ts b/__test__/components/Event/utils/dateTimeHelpers.test.ts new file mode 100644 index 0000000..f6ceb29 --- /dev/null +++ b/__test__/components/Event/utils/dateTimeHelpers.test.ts @@ -0,0 +1,178 @@ +import { + detectDateTimeFormat, + convertFormDateTimeToISO, + DATETIME_WITH_SECONDS_LENGTH, + DATETIME_FORMAT_WITH_SECONDS, + DATETIME_FORMAT_WITHOUT_SECONDS, +} from "../../../../src/components/Event/utils/dateTimeHelpers"; + +describe("dateTimeHelpers", () => { + describe("Constants", () => { + it("should have correct constant values", () => { + expect(DATETIME_WITH_SECONDS_LENGTH).toBe(19); + expect(DATETIME_FORMAT_WITH_SECONDS).toBe("YYYY-MM-DDTHH:mm:ss"); + expect(DATETIME_FORMAT_WITHOUT_SECONDS).toBe("YYYY-MM-DDTHH:mm"); + }); + }); + + describe("detectDateTimeFormat", () => { + it("should return format with seconds for length >= 19", () => { + expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe( + DATETIME_FORMAT_WITH_SECONDS + ); + expect(detectDateTimeFormat("2024-01-15T10:30:45")).toBe( + "YYYY-MM-DDTHH:mm:ss" + ); + }); + + it("should return format without seconds for length < 19", () => { + expect(detectDateTimeFormat("2024-01-15T10:30")).toBe( + DATETIME_FORMAT_WITHOUT_SECONDS + ); + expect(detectDateTimeFormat("2024-01-15T10:30")).toBe("YYYY-MM-DDTHH:mm"); + }); + + it("should return format with seconds for length exactly 19", () => { + const datetime = "2024-01-15T10:30:45"; + expect(datetime.length).toBe(19); + expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); + }); + + it("should return format without seconds for length 16", () => { + const datetime = "2024-01-15T10:30"; + expect(datetime.length).toBe(16); + expect(detectDateTimeFormat(datetime)).toBe( + DATETIME_FORMAT_WITHOUT_SECONDS + ); + }); + + it("should return format with seconds for length > 19", () => { + const datetime = "2024-01-15T10:30:45.123"; + expect(datetime.length).toBeGreaterThan(19); + expect(detectDateTimeFormat(datetime)).toBe(DATETIME_FORMAT_WITH_SECONDS); + }); + + it("should handle empty string", () => { + expect(detectDateTimeFormat("")).toBe(DATETIME_FORMAT_WITHOUT_SECONDS); + }); + + it("should handle very short strings", () => { + expect(detectDateTimeFormat("2024")).toBe( + DATETIME_FORMAT_WITHOUT_SECONDS + ); + }); + }); + + describe("convertFormDateTimeToISO", () => { + const originalConsoleWarn = console.warn; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it("should convert valid datetime without seconds to ISO string", () => { + const result = convertFormDateTimeToISO( + "2024-01-15T10:30", + "America/New_York" + ); + expect(result).toBeTruthy(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it("should convert valid datetime with seconds to ISO string", () => { + const result = convertFormDateTimeToISO( + "2024-01-15T10:30:45", + "America/New_York" + ); + expect(result).toBeTruthy(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it("should use Etc/UTC as default timezone when timezone is empty", () => { + const result = convertFormDateTimeToISO("2024-01-15T10:30", ""); + expect(result).toBeTruthy(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("should return empty string for empty datetime input", () => { + const result = convertFormDateTimeToISO("", "America/New_York"); + expect(result).toBe(""); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it("should return empty string and log warning for invalid datetime", () => { + const result = convertFormDateTimeToISO( + "invalid-date", + "America/New_York" + ); + expect(result).toBe(""); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("[convertFormDateTimeToISO] Invalid datetime:") + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('"invalid-date"') + ); + }); + + it("should return empty string and log warning for invalid format", () => { + const result = convertFormDateTimeToISO( + "2024-13-45T25:99:99", + "America/New_York" + ); + expect(result).toBe(""); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); + + it("should handle different timezones correctly", () => { + const result1 = convertFormDateTimeToISO( + "2024-01-15T10:30", + "America/New_York" + ); + const result2 = convertFormDateTimeToISO( + "2024-01-15T10:30", + "Europe/London" + ); + expect(result1).toBeTruthy(); + expect(result2).toBeTruthy(); + expect(result1).not.toBe(result2); + }); + + it("should handle edge case with null/undefined timezone", () => { + const result = convertFormDateTimeToISO( + "2024-01-15T10:30", + // @ts-ignore - testing edge case + null + ); + expect(result).toBeTruthy(); + }); + + it("should convert correctly for UTC timezone", () => { + const result = convertFormDateTimeToISO("2024-01-15T10:30", "Etc/UTC"); + expect(result).toBeTruthy(); + expect(result).toContain("T10:30:00"); + }); + + it("should handle datetime at boundary (exactly 19 characters)", () => { + const datetime = "2024-01-15T10:30:45"; + expect(datetime.length).toBe(19); + const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); + expect(result).toBeTruthy(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it("should handle datetime at boundary (exactly 16 characters)", () => { + const datetime = "2024-01-15T10:30"; + expect(datetime.length).toBe(16); + const result = convertFormDateTimeToISO(datetime, "Etc/UTC"); + expect(result).toBeTruthy(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 020e1c8..cd51779 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -59,6 +59,7 @@ describe("parseCalendarEvent", () => { expect(result.transp).toBe("OPAQUE"); expect(result.class).toBe("PUBLIC"); expect(result.x_openpass_videoconference).toBe("https://meet.link"); + expect(result.timezone).toBe("Etc/UTC"); expect(result.organizer).toEqual({ cn: "Alice", @@ -120,6 +121,7 @@ describe("parseCalendarEvent", () => { expect(result.transp).toBe("OPAQUE"); expect(result.class).toBe("PUBLIC"); expect(result.x_openpass_videoconference).toBe("https://meet.link"); + expect(result.timezone).toBe("Etc/UTC"); expect(result.organizer).toEqual({ cn: "Alice", @@ -211,7 +213,11 @@ describe("parseCalendarEvent", () => { calendarId, "/calendars/test.ics" ); - expect(result.end).toBe("2025-07-18T10:00:00"); + expect(result.end).toBeDefined(); + expect(result.timezone).toBeDefined(); + const endDate = new Date(result.end); + const startDate = new Date(result.start); + expect(endDate.getTime() - startDate.getTime()).toBe(60 * 60 * 1000); }); it("returns error if end and duration is missing", () => { @@ -259,6 +265,7 @@ describe("parseCalendarEvent", () => { const rawData = [ ["UID", {}, "text", "event-4"], ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], + ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], ["ATTENDEE", {}, "cal-address", "john@example.com"], ["ORGANIZER", {}, "cal-address", "jane@example.com"], ] as unknown as [string, Record, string, any]; @@ -286,6 +293,66 @@ describe("parseCalendarEvent", () => { cal_address: "jane@example.com", }); }); + + it("converts datetime without timezone to ISO UTC when timezone is detected", () => { + const rawData = [ + ["UID", {}, "text", "event-tz"], + ["DTSTART", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T09:00:00"], + ["DTEND", { tzid: "Asia/Bangkok" }, "date-time", "2025-07-18T10:00:00"], + ] as unknown as [string, Record, string, any]; + + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); + + expect(result.timezone).toBeDefined(); + expect(result.start).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(result.allday).toBe(false); + }); + + it("does not convert datetime with Z suffix", () => { + const rawData = [ + ["UID", {}, "text", "event-utc"], + ["DTSTART", {}, "date-time", "2025-07-18T09:00:00Z"], + ["DTEND", {}, "date-time", "2025-07-18T10:00:00Z"], + ] as unknown as [string, Record, string, any]; + + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); + + expect(result.start).toBe("2025-07-18T09:00:00Z"); + expect(result.end).toBe("2025-07-18T10:00:00Z"); + expect(result.timezone).toBe("Etc/UTC"); + }); + + it("preserves all-day event dates without conversion", () => { + const rawData = [ + ["UID", {}, "text", "event-allday"], + ["DTSTART", {}, "date", "2025-07-18"], + ["DTEND", {}, "date", "2025-07-19"], + ] as unknown as [string, Record, string, any]; + + const result = parseCalendarEvent( + rawData, + baseColor, + calendarId, + "/calendars/test.ics" + ); + + expect(result.allday).toBe(true); + expect(result.start).toBe("2025-07-18"); + expect(result.end).toBe("2025-07-19"); + }); }); describe("calendarEventToJCal", () => { @@ -304,8 +371,8 @@ describe("calendarEventToJCal", () => { URL: "/calendars/test.ics", calId: "test/test", title: "Team Meeting", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T08:00:00.000Z", + end: "2025-07-23T09:00:00.000Z", timezone: "Europe/Paris", transp: "OPAQUE", class: "PUBLIC", @@ -336,18 +403,24 @@ describe("calendarEventToJCal", () => { expect(vevent[0]).toBe("vevent"); const props = vevent[1]; + const dtstart = props.find((p: any[]) => p[0] === "dtstart"); + const dtend = props.find((p: any[]) => p[0] === "dtend"); + + expect(dtstart).toBeDefined(); + expect(dtstart[1]).toEqual({ tzid: "Europe/Paris" }); + expect(dtstart[2]).toBe("date-time"); + expect(dtstart[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + + expect(dtend).toBeDefined(); + expect(dtend[1]).toEqual({ tzid: "Europe/Paris" }); + expect(dtend[2]).toBe("date-time"); + expect(dtend[3]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(props).toEqual( expect.arrayContaining([ ["uid", {}, "text", "event-123"], ["summary", {}, "text", "Team Meeting"], ["transp", {}, "text", "OPAQUE"], - [ - "dtstart", - { tzid: "Europe/Paris" }, - "date-time", - "2025-07-23T10:00:00", - ], - ["dtend", { tzid: "Europe/Paris" }, "date-time", "2025-07-23T11:00:00"], ["class", {}, "text", "PUBLIC"], ["location", {}, "text", "Room 101"], ["description", {}, "text", "Discuss project roadmap."], @@ -416,8 +489,8 @@ describe("calendarEventToJCal", () => { const mockEvent: any = { uid: "event-10", title: "Alarm Event", - start: new Date("2025-07-20T09:00:00"), - end: new Date("2025-07-20T10:00:00"), + start: "2025-07-20T07:00:00.000Z", + end: "2025-07-20T08:00:00.000Z", timezone: "Europe/Paris", allday: false, alarm: { trigger: "-PT10M", action: "DISPLAY" }, @@ -440,8 +513,8 @@ describe("calendarEventToJCal", () => { const mockEvent: any = { uid: "event-11", title: "All Day", - start: new Date("2025-07-21"), - end: new Date("2025-07-21"), + start: "2025-07-21T00:00:00.000Z", + end: "2025-07-21T00:00:00.000Z", timezone: "Europe/Paris", allday: true, attendee: [], @@ -464,8 +537,8 @@ describe("calendarEventToJCal", () => { URL: "/calendars/test.ics", calId: "test/test", title: "Team Meeting", - start: new Date("2025-07-23"), - end: new Date("2025-07-23"), + start: "2025-07-23T00:00:00.000Z", + end: "2025-07-23T00:00:00.000Z", timezone: "Europe/Paris", transp: "OPAQUE", class: "PUBLIC", @@ -572,8 +645,8 @@ describe("calendarEventToJCal", () => { const mockEvent = { uid: "event-invalid-tz", title: "Invalid Timezone Event", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T10:00:00.000Z", + end: "2025-07-23T11:00:00.000Z", timezone: "Invalid/Timezone", allday: false, attendee: [], @@ -607,8 +680,8 @@ describe("calendarEventToJCal", () => { const mockEvent = { uid: "event-null-tz", title: "Null Timezone Event", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T10:00:00.000Z", + end: "2025-07-23T11:00:00.000Z", timezone: null, allday: false, attendee: [], @@ -642,8 +715,8 @@ describe("calendarEventToJCal", () => { const mockEvent = { uid: "event-undefined-tz", title: "Undefined Timezone Event", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T10:00:00.000Z", + end: "2025-07-23T11:00:00.000Z", timezone: undefined, allday: false, attendee: [], @@ -677,8 +750,8 @@ describe("calendarEventToJCal", () => { const mockEvent = { uid: "event-empty-tz", title: "Empty Timezone Event", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T10:00:00.000Z", + end: "2025-07-23T11:00:00.000Z", timezone: "", allday: false, attendee: [], @@ -712,8 +785,8 @@ describe("calendarEventToJCal", () => { const mockEvent = { uid: "event-valid-tz", title: "Valid Timezone Event", - start: new Date("2025-07-23T10:00:00"), - end: new Date("2025-07-23T11:00:00"), + start: "2025-07-23T08:00:00.000Z", + end: "2025-07-23T09:00:00.000Z", timezone: "Europe/Paris", allday: false, attendee: [], @@ -792,8 +865,9 @@ describe("combineMasterDateWithFormTime", () => { mockFormatDateTime ); - expect(result.startDate).toBe("2025-10-14"); - expect(result.endDate).toBe("2025-10-14"); + // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues + expect(result.startDate).toBe("2025-10-14T00:00:00.000Z"); + expect(result.endDate).toBe("2025-10-14T00:00:00.000Z"); }); it("should handle all-day events with missing end date", () => { @@ -811,8 +885,10 @@ describe("combineMasterDateWithFormTime", () => { mockFormatDateTime ); - expect(result.startDate).toBe("2025-10-14"); - expect(result.endDate).toBe("2025-10-14"); + // Function now returns ISO UTC strings for all-day events to avoid timezone offset issues + // When end is missing, it uses start date for end date + expect(result.startDate).toBe("2025-10-14T00:00:00.000Z"); + expect(result.endDate).toBe("2025-10-14T00:00:00.000Z"); }); it("should combine master date with form time for timed events", () => { diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 0de6426..868d851 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -7,6 +7,34 @@ import moment from "moment-timezone"; import { refreshSingularCalendar } from "../../Event/utils/eventUtils"; import { ThunkDispatch } from "@reduxjs/toolkit"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; +import { detectDateTimeFormat } from "../../Event/utils/dateTimeHelpers"; + +function convertEventDateTimeToISO( + datetime: string, + eventTimezone: string, + isAllDay: boolean +): string { + if (!datetime || isAllDay) return datetime; + + if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { + return datetime; + } + + const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + if (dateOnlyRegex.test(datetime)) { + return datetime; + } + + const format = detectDateTimeFormat(datetime); + const momentDate = moment.tz(datetime, format, eventTimezone); + if (!momentDate.isValid()) { + console.warn( + `[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"` + ); + return datetime; + } + return momentDate.toISOString(); +} export const updateSlotLabelVisibility = ( currentTime: Date, @@ -72,20 +100,39 @@ export const eventToFullCalendarFormat = ( return filteredEvents .concat(filteredTempEvents.map((e) => ({ ...e, temp: true }))) .map((e) => { - if (e.calId.split("/")[0] === userId) { - return { - ...e, - title: formatEventChipTitle(e, t), - colors: e.color, - editable: true, - }; - } - return { + const eventTimezone = e.timezone || "Etc/UTC"; + const isAllDay = e.allday ?? false; + + const convertedEvent: any = { ...e, title: formatEventChipTitle(e, t), colors: e.color, - editable: false, + editable: e.calId.split("/")[0] === userId, }; + + if (!isAllDay && e.start && eventTimezone) { + const startISO = convertEventDateTimeToISO( + e.start, + eventTimezone, + isAllDay + ); + if (startISO) { + convertedEvent.start = startISO; + } + } + + if (!isAllDay && e.end && eventTimezone) { + const endISO = convertEventDateTimeToISO( + e.end, + eventTimezone, + isAllDay + ); + if (endISO) { + convertedEvent.end = endISO; + } + } + + return convertedEvent; }); }; diff --git a/src/components/Event/utils/dateTimeHelpers.ts b/src/components/Event/utils/dateTimeHelpers.ts index 78454d7..3549b6b 100644 --- a/src/components/Event/utils/dateTimeHelpers.ts +++ b/src/components/Event/utils/dateTimeHelpers.ts @@ -1,7 +1,24 @@ +import moment from "moment-timezone"; + /** * Helper functions for date/time string manipulation */ +export const DATETIME_WITH_SECONDS_LENGTH = 19; +export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss"; +export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm"; + +/** + * Detect datetime format based on string length + * @param datetime - Datetime string to analyze + * @returns Format string for moment parsing + */ +export function detectDateTimeFormat(datetime: string): string { + return datetime.length >= DATETIME_WITH_SECONDS_LENGTH + ? DATETIME_FORMAT_WITH_SECONDS + : DATETIME_FORMAT_WITHOUT_SECONDS; +} + /** * Split datetime string (YYYY-MM-DDTHH:mm) into date and time parts * @param datetime - ISO datetime string @@ -30,3 +47,24 @@ export function combineDateTime(date: string, time: string): string { if (!time) return date; // Date only for all-day return `${date}T${time}`; } + +/** + * Convert a local form datetime string to ISO string in a specific timezone + * Assumes input format YYYY-MM-DDTHH:mm (24h) + */ +export function convertFormDateTimeToISO( + datetime: string, + timezone: string +): string { + if (!datetime) return ""; + const tz = timezone || "Etc/UTC"; + const format = detectDateTimeFormat(datetime); + const momentDate = moment.tz(datetime, format, tz); + if (!momentDate.isValid()) { + console.warn( + `[convertFormDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${tz}"` + ); + return ""; + } + return momentDate.toDate().toISOString(); +} diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 2cd56b9..961967a 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -793,8 +793,10 @@ const CalendarSlice = createSlice({ state[type][action.payload.calId].color; state[type][action.payload.calId].events[id].calId = action.payload.calId; - state[type][action.payload.calId].events[id].timezone = - Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!state[type][action.payload.calId].events[id].timezone) { + state[type][action.payload.calId].events[id].timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + } } ); } @@ -827,8 +829,10 @@ const CalendarSlice = createSlice({ state[type][action.payload.calId].color; state[type][action.payload.calId].events[id].calId = action.payload.calId; - state[type][action.payload.calId].events[id].timezone = - Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!state[type][action.payload.calId].events[id].timezone) { + state[type][action.payload.calId].events[id].timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + } } ); } @@ -872,8 +876,10 @@ const CalendarSlice = createSlice({ state.list[action.payload.calId].color; state.list[action.payload.calId].events[id].calId = action.payload.calId; - state.list[action.payload.calId].events[id].timezone = - Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!state.list[action.payload.calId].events[id].timezone) { + state.list[action.payload.calId].events[id].timezone = + Intl.DateTimeFormat().resolvedOptions().timeZone; + } }); } ) diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 03562b4..8263d89 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -8,6 +8,19 @@ import { parseCalendarEvent, } from "./eventUtils"; import ICAL from "ical.js"; +import moment from "moment-timezone"; +import { detectDateTimeFormat } from "../../components/Event/utils/dateTimeHelpers"; + +function resolveTimezoneId(tzid?: string): string | undefined { + if (!tzid) return undefined; + if (TIMEZONES.zones[tzid]) { + return tzid; + } + if (TIMEZONES.aliases[tzid]) { + return TIMEZONES.aliases[tzid].aliasTo; + } + return tzid; +} export async function getEvent(event: CalendarEvent, isMaster?: boolean) { const response = await api.get(`dav${event.URL}`); @@ -17,33 +30,130 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { const vevents = (eventical[2] || []).filter( ([name]: [string]) => name.toLowerCase() === "vevent" ); + + const vtimezones = (eventical[2] || []).filter( + ([name]: [string]) => name.toLowerCase() === "vtimezone" + ); + let targetVevent; if (isMaster) { - // Find master VEVENT (the one without recurrence-id) - targetVevent = vevents.find( ([, props]: [string, any[]]) => !props.find(([k]: string[]) => k.toLowerCase() === "recurrence-id") ); if (!targetVevent) { - // Fallback to first VEVENT if no master found targetVevent = vevents[0]; } } else { - // For non-master, use first VEVENT as before targetVevent = vevents[0]; } + let timezoneFromVTimezone: string | undefined; + if (vtimezones.length > 0) { + const vtimezone = vtimezones[0]; + const tzidProp = vtimezone[1]?.find( + ([k]: string[]) => k.toLowerCase() === "tzid" + ); + if (tzidProp && tzidProp[3]) { + const resolvedTz = resolveTimezoneId(tzidProp[3]); + if (resolvedTz) { + timezoneFromVTimezone = resolvedTz; + } + } + } + + let timezoneFromDTSTART: string | undefined; + const dtstartProp = targetVevent[1]?.find( + ([k]: string[]) => k.toLowerCase() === "dtstart" + ); + if (dtstartProp) { + const dtstartParams = dtstartProp[1]; + const dtstartValue = dtstartProp[3]; + if (dtstartParams) { + const tzParam = + dtstartParams.tzid || + dtstartParams.TZID || + dtstartParams.Tzid || + dtstartParams.tZid || + dtstartParams.tzId; + if (tzParam) { + const resolvedTz = resolveTimezoneId(tzParam); + if (resolvedTz) { + timezoneFromDTSTART = resolvedTz; + } + } + } + if ( + !timezoneFromDTSTART && + typeof dtstartValue === "string" && + dtstartValue.endsWith("Z") + ) { + timezoneFromDTSTART = "Etc/UTC"; + } + } + const eventjson = parseCalendarEvent( targetVevent[1], event.color ?? {}, event.calId, event.URL ); - if (isMaster) { - return { ...event, ...eventjson }; + + const finalTimezone = + timezoneFromVTimezone || + timezoneFromDTSTART || + eventjson.timezone || + "Etc/UTC"; + eventjson.timezone = finalTimezone; + + if (!eventjson.allday && eventjson.start && finalTimezone) { + const startISO = convertEventDateTimeToISO(eventjson.start, finalTimezone); + if (startISO) { + eventjson.start = startISO; + } } - return { ...eventjson, ...event }; + + if (!eventjson.allday && eventjson.end && finalTimezone) { + const endISO = convertEventDateTimeToISO(eventjson.end, finalTimezone); + if (endISO) { + eventjson.end = endISO; + } + } + + if (isMaster) { + const merged = { ...event, ...eventjson }; + merged.timezone = finalTimezone; + return merged; + } + const merged = { ...event, ...eventjson }; + merged.timezone = finalTimezone; + return merged; +} + +function convertEventDateTimeToISO( + datetime: string, + eventTimezone: string +): string | undefined { + if (!datetime || !eventTimezone) return undefined; + + if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { + return datetime; + } + + const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + if (dateOnlyRegex.test(datetime)) { + return undefined; + } + + const format = detectDateTimeFormat(datetime); + const momentDate = moment.tz(datetime, format, eventTimezone); + if (!momentDate.isValid()) { + console.warn( + `[convertEventDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${eventTimezone}"` + ); + return undefined; + } + return momentDate.toISOString(); } export async function dlEvent(event: CalendarEvent) { diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index d62f1be..722d33e 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -29,6 +29,7 @@ import { formatLocalDateTime, formatDateTimeInTimezone, } from "../../components/Event/utils/dateTimeFormatters"; +import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers"; import { addDays } from "../../components/Event/utils/dateRules"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; @@ -82,6 +83,11 @@ function EventPopover({ }, []); const calendarTimezone = useAppSelector((state) => state.calendars.timeZone); + const resolvedCalendarTimezone = useMemo(() => { + const tz = + calendarTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + return resolveTimezone(tz); + }, [calendarTimezone]); const [showMore, setShowMore] = useState(false); const [showDescription, setShowDescription] = useState( @@ -97,8 +103,13 @@ function EventPopover({ const [location, setLocation] = useState(event?.location ?? ""); const [start, setStart] = useState(event?.start ? event.start : ""); const [end, setEnd] = useState(event?.end ? event.end : ""); + const defaultCalendarId = useMemo( + () => userPersonalCalendars[0]?.id ?? "", + [userPersonalCalendars] + ); + const [calendarid, setCalendarid] = useState( - event?.calId ?? userPersonalCalendars[0]?.id ?? "" + event?.calId ?? defaultCalendarId ); const [allday, setAllDay] = useState(event?.allday ?? false); const [repetition, setRepetition] = useState( @@ -113,7 +124,7 @@ function EventPopover({ const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); const [timezone, setTimezone] = useState( - event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone + event?.timezone ? resolveTimezone(event.timezone) : resolvedCalendarTimezone ); const [hasVideoConference, setHasVideoConference] = useState( event?.x_openpass_videoconference ? true : false @@ -135,10 +146,10 @@ function EventPopover({ }, [userPersonalCalendars]); useEffect(() => { - if (!calendarid && userPersonalCalendars.length > 0) { - setCalendarid(userPersonalCalendars[0].id); + if (!calendarid && defaultCalendarId) { + setCalendarid(defaultCalendarId); } - }, [calendarid, userPersonalCalendars]); + }, [calendarid, defaultCalendarId]); const resetAllStateToDefault = useCallback(() => { setShowMore(false); @@ -150,22 +161,18 @@ function EventPopover({ setLocation(""); setStart(""); setEnd(""); - if ( - userPersonalCalendars && - userPersonalCalendars.length > 0 && - userPersonalCalendars[0]?.id - ) { - setCalendarid(userPersonalCalendars[0].id); + if (defaultCalendarId) { + setCalendarid(defaultCalendarId); } setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); - setTimezone(calendarTimezone); + setTimezone(resolvedCalendarTimezone); setHasVideoConference(false); setMeetingLink(null); - }, [calendarTimezone, userPersonalCalendars]); + }, [resolvedCalendarTimezone, defaultCalendarId]); // Track if we should sync from selectedRange (only on initial selection, not on toggle) const shouldSyncFromRangeRef = useRef(true); @@ -183,24 +190,22 @@ function EventPopover({ // Set timezone to calendar timezone for new events when opening const isNewEvent = !event || !event.uid; if (isNewEvent) { - const resolvedTimezone = resolveTimezone(calendarTimezone); - setTimezone(resolvedTimezone); + setTimezone(resolvedCalendarTimezone); } } // Update previous open state prevOpenRef.current = open; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, event?.uid, calendarTimezone]); + }, [open, event?.uid, resolvedCalendarTimezone]); // Separately sync timezone when calendarTimezone changes while modal is open for new events useEffect(() => { if (open && (!event || !event.uid)) { - const resolvedTimezone = resolveTimezone(calendarTimezone); - setTimezone(resolvedTimezone); + setTimezone(resolvedCalendarTimezone); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [calendarTimezone, open, event?.uid]); + }, [resolvedCalendarTimezone, open, event?.uid]); // Set start/end times when modal opens for new event creation useEffect(() => { @@ -360,15 +365,18 @@ function EventPopover({ setDescription(event.description ?? ""); setLocation(event.location ?? ""); - // Get event's timezone for formatting - const eventTimezone = event.timezone - ? resolveTimezone(event.timezone) - : calendarTimezone; - // Handle all-day events properly const isAllDay = event.allday ?? false; setAllDay(isAllDay); + // Get event's timezone for formatting - prioritize event.timezone from server + let eventTimezone: string; + if (event.timezone) { + eventTimezone = resolveTimezone(event.timezone); + } else { + eventTimezone = resolvedCalendarTimezone; + } + // Format dates based on all-day status and timezone if (event.start) { if (isAllDay) { @@ -396,12 +404,8 @@ function EventPopover({ setEnd(""); } - if ( - userPersonalCalendars && - userPersonalCalendars.length > 0 && - userPersonalCalendars[0]?.id - ) { - setCalendarid(userPersonalCalendars[0].id); + if (defaultCalendarId) { + setCalendarid(defaultCalendarId); } setRepetition(event.repetition ?? ({} as RepetitionObject)); setShowRepeat(event.repetition?.freq ? true : false); @@ -439,7 +443,12 @@ function EventPopover({ event.attendee.filter((a) => a.cal_address !== organizer?.cal_address) ); } - }, [event, organizer?.cal_address, calendarTimezone]); + }, [ + event, + organizer?.cal_address, + resolvedCalendarTimezone, + defaultCalendarId, + ]); // Reset state when creating new event (event is empty object or undefined) useEffect(() => { @@ -457,12 +466,8 @@ function EventPopover({ setDescription(""); setAttendees([]); setLocation(""); - if ( - userPersonalCalendars && - userPersonalCalendars.length > 0 && - userPersonalCalendars[0]?.id - ) { - setCalendarid(userPersonalCalendars[0].id); + if (defaultCalendarId) { + setCalendarid(defaultCalendarId); } setAllDay(false); setRepetition({} as RepetitionObject); @@ -476,8 +481,7 @@ function EventPopover({ if (!isCreatingNew) { isInitializedRef.current = true; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [event?.uid]); + }, [event, defaultCalendarId]); const handleStartChange = useCallback( (newStart: string) => { @@ -626,12 +630,21 @@ function EventPopover({ const endDateOnlyUI = (end || start || "").split("T")[0]; // For all-day events, API needs end date = UI end date + 1 day const endDateOnlyAPI = addDays(endDateOnlyUI, 1); - const startDateObj = new Date(`${startDateOnly}T00:00:00`); - const endDateObj = new Date(`${endDateOnlyAPI}T00:00:00`); + // Parse date string and create Date at UTC midnight to avoid timezone offset issues + const [startYear, startMonth, startDay] = startDateOnly + .split("-") + .map(Number); + const [endYear, endMonth, endDay] = endDateOnlyAPI.split("-").map(Number); + const startDateObj = new Date( + Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) + ); + const endDateObj = new Date( + Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) + ); newEvent.start = startDateObj.toISOString(); newEvent.end = endDateObj.toISOString(); } else { - newEvent.start = new Date(start).toISOString(); + newEvent.start = convertFormDateTimeToISO(start, timezone); if (end) { // In normal mode, only override end date when the end date field is not shown if (!showMore && !hasEndDateChanged) { @@ -640,10 +653,10 @@ function EventPopover({ ? end.split("T")[1]?.slice(0, 5) || "00:00" : "00:00"; const endDateTime = `${startDateOnly}T${endTimeOnly}`; - newEvent.end = new Date(endDateTime).toISOString(); + newEvent.end = convertFormDateTimeToISO(endDateTime, timezone); } else { // Extended mode or end date explicitly shown in normal mode: use actual end datetime - newEvent.end = new Date(end).toISOString(); + newEvent.end = convertFormDateTimeToISO(end, timezone); } } } @@ -669,7 +682,7 @@ function EventPopover({ }) ); if (tempList) { - const calendarRange = getCalendarRange(new Date(start)); + const calendarRange = getCalendarRange(new Date(newEvent.start)); await updateTempCalendar(tempList, newEvent, dispatch, calendarRange); } }; diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index a12c64c..13bd72e 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -30,6 +30,7 @@ import { import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { updateAttendeesAfterTimeChange } from "../../components/Calendar/handlers/eventHandlers"; +import { convertFormDateTimeToISO } from "../../components/Event/utils/dateTimeHelpers"; const showErrorNotification = (message: string) => { console.error(`[ERROR] ${message}`); @@ -157,10 +158,13 @@ function EventUpdateModal({ resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone) ); const [newCalId, setNewCalId] = useState(calId); - const [calendarid, setCalendarid] = useState( - calId ?? userPersonalCalendars[0]?.id ?? "" + const defaultCalendarId = useMemo( + () => userPersonalCalendars[0]?.id ?? "", + [userPersonalCalendars] ); + const [calendarid, setCalendarid] = useState(calId ?? defaultCalendarId); + const [attendees, setAttendees] = useState([]); const [hasVideoConference, setHasVideoConference] = useState(false); const [meetingLink, setMeetingLink] = useState(null); @@ -178,7 +182,9 @@ function EventUpdateModal({ setLocation(""); setStart(""); setEnd(""); - setCalendarid(userPersonalCalendars[0].id); + if (defaultCalendarId) { + setCalendarid(defaultCalendarId); + } setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); @@ -189,7 +195,7 @@ function EventUpdateModal({ ); setHasVideoConference(false); setMeetingLink(null); - }, []); + }, [defaultCalendarId]); // Prevent repeated initialization loops const initializedKeyRef = useRef(null); @@ -276,10 +282,15 @@ function EventUpdateModal({ setEventClass(event.class ?? "PUBLIC"); setBusy(event.transp ?? "OPAQUE"); - const resolvedTimezone = event.timezone - ? resolveTimezone(event.timezone) - : resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); - setTimezone(resolvedTimezone); + if (event.timezone) { + const resolvedTimezone = resolveTimezone(event.timezone); + setTimezone(resolvedTimezone); + } else { + const browserTz = resolveTimezone( + Intl.DateTimeFormat().resolvedOptions().timeZone + ); + setTimezone(browserTz); + } setHasVideoConference(event.x_openpass_videoconference ? true : false); setMeetingLink(event.x_openpass_videoconference || null); setNewCalId(event.calId || calId); @@ -384,15 +395,29 @@ function EventUpdateModal({ // For single events or "solo" edits, use the edited dates from form if (allday) { // For all-day events, use date format (YYYY-MM-DD) + // Extract date string directly to avoid timezone conversion issues + const startDateOnly = (start || "").split("T")[0]; + const endDateOnlyUI = (end || start || "").split("T")[0]; // API needs end date = UI end date + 1 day - const startDateOnly = new Date(start).toISOString().split("T")[0]; - const endDateOnlyUI = new Date(end).toISOString().split("T")[0]; const endDateOnlyAPI = addDays(endDateOnlyUI, 1); - startDate = startDateOnly; - endDate = endDateOnlyAPI; + // Parse date string and create Date at UTC midnight to avoid timezone offset issues + const [startYear, startMonth, startDay] = startDateOnly + .split("-") + .map(Number); + const [endYear, endMonth, endDay] = endDateOnlyAPI + .split("-") + .map(Number); + const startDateObj = new Date( + Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) + ); + const endDateObj = new Date( + Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) + ); + startDate = startDateObj.toISOString(); + endDate = endDateObj.toISOString(); } else { // For timed events - startDate = new Date(start).toISOString(); + startDate = convertFormDateTimeToISO(start, timezone); // In normal mode, only override end date when the end date field is not shown if (!showMore && !hasEndDateChanged) { const startDateOnly = (start || "").split("T")[0]; @@ -400,10 +425,10 @@ function EventUpdateModal({ ? end.split("T")[1]?.slice(0, 5) || "00:00" : "00:00"; const endDateTime = `${startDateOnly}T${endTimeOnly}`; - endDate = new Date(endDateTime).toISOString(); + endDate = convertFormDateTimeToISO(endDateTime, timezone); } else { // Extended mode: use actual end datetime - endDate = new Date(end).toISOString(); + endDate = convertFormDateTimeToISO(end, timezone); } } } @@ -520,7 +545,7 @@ function EventUpdateModal({ // Keep modal open on error, user can retry or cancel } if (tempList) { - const calendarRange = getCalendarRange(new Date(start)); + const calendarRange = getCalendarRange(new Date(startDate)); await updateTempCalendar(tempList, event, dispatch, calendarRange); } return; @@ -586,7 +611,7 @@ function EventUpdateModal({ ).unwrap(); // STEP 3: Fetch to get new instances with correct timing - const calendarRange = getCalendarRange(new Date(start)); + const calendarRange = getCalendarRange(new Date(startDate)); await refreshCalendars( dispatch, Object.values(calendarsList), @@ -695,7 +720,7 @@ function EventUpdateModal({ dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid })); } if (tempList) { - const calendarRange = getCalendarRange(new Date(start)); + const calendarRange = getCalendarRange(new Date(startDate)); await updateTempCalendar(tempList, event, dispatch, calendarRange); } }; diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index b2c340e..7ee2d35 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -2,9 +2,53 @@ import { userAttendee } from "../User/userDataTypes"; import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes"; import ICAL from "ical.js"; import { TIMEZONES } from "../../utils/timezone-data"; -import moment from "moment"; +import moment from "moment-timezone"; +import { + convertFormDateTimeToISO, + detectDateTimeFormat, +} from "../../components/Event/utils/dateTimeHelpers"; type RawEntry = [string, Record, string, any]; +function resolveTimezoneId(tzid?: string): string | undefined { + if (!tzid) return undefined; + if (TIMEZONES.zones[tzid]) { + return tzid; + } + const alias = TIMEZONES.aliases[tzid]; + if (alias) { + return alias.aliasTo; + } + return tzid; +} + +function inferTimezoneFromValue( + params: Record | undefined, + value: string +): string | undefined { + if (!params) { + if (typeof value === "string" && value.endsWith("Z")) { + return "Etc/UTC"; + } + return undefined; + } + + const tzParam = + params.tzid || params.TZID || params.Tzid || params.tZid || params.tzId; + + if (tzParam) { + const resolved = resolveTimezoneId(tzParam); + if (resolved) { + return resolved; + } + } + + if (typeof value === "string" && value.endsWith("Z")) { + return "Etc/UTC"; + } + + return undefined; +} + export function parseCalendarEvent( data: RawEntry[], color: Record, @@ -25,22 +69,34 @@ export function parseCalendarEvent( case "transp": event.transp = value; break; - case "dtstart": + case "dtstart": { event.start = value; + const detectedTz = inferTimezoneFromValue(params, value); + if (detectedTz) { + event.timezone = detectedTz; + } if (dateRegex.test(value)) { event.allday = true; } else { event.allday = false; } break; - case "dtend": + } + case "dtend": { event.end = value; + if (!event.timezone) { + const detectedTz = inferTimezoneFromValue(params, value); + if (detectedTz) { + event.timezone = detectedTz; + } + } if (dateRegex.test(value)) { event.allday = true; } else { event.allday = false; } break; + } case "class": event.class = value; break; @@ -139,16 +195,60 @@ export function parseCalendarEvent( ); event.error = `missing crucial event param in calendar ${calendarid} `; } + + const eventTimezone = event.timezone || "Etc/UTC"; + event.timezone = eventTimezone; + if (!event.end) { const start = event.start ? new Date(event.start) : new Date(); const timeToAdd = moment.duration(duration).asMilliseconds(); const artificialEnd = new Date(start.getTime() + timeToAdd); - event.end = formatDateToICal(artificialEnd, false); + event.end = formatDateToICal(artificialEnd, false, eventTimezone); + } + + if (!event.allday && event.start && eventTimezone) { + const startISO = convertDateTimeStringToISO(event.start, eventTimezone); + if (startISO) { + event.start = startISO; + } + } + + if (!event.allday && event.end && eventTimezone) { + const endISO = convertDateTimeStringToISO(event.end, eventTimezone); + if (endISO) { + event.end = endISO; + } } return event as CalendarEvent; } +function convertDateTimeStringToISO( + datetime: string, + timezone: string +): string | undefined { + if (!datetime || !timezone) return undefined; + + if (datetime.includes("Z") || datetime.match(/[+-]\d{2}:\d{2}$/)) { + return undefined; + } + + const dateOnlyRegex = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + if (dateOnlyRegex.test(datetime)) { + return undefined; + } + + const format = detectDateTimeFormat(datetime); + const momentDate = moment.tz(datetime, format, timezone); + if (!momentDate.isValid()) { + console.warn( + `[convertDateTimeStringToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${timezone}"` + ); + return undefined; + } + return momentDate.toISOString(); +} + export function calendarEventToJCal( event: CalendarEvent, calOwnerEmail?: string @@ -194,7 +294,7 @@ export function makeVevent( "dtstart", { tzid }, event.allday ? "date" : "date-time", - formatDateToICal(new Date(event.start), event.allday ?? false), + formatDateToICal(new Date(event.start), event.allday ?? false, tzid), ], ["class", {}, "text", event.class ?? "PUBLIC"], [ @@ -237,7 +337,7 @@ export function makeVevent( "dtend", { tzid }, event.allday ? "date" : "date-time", - formatDateToICal(finalEndDate, event.allday ?? false), + formatDateToICal(finalEndDate, event.allday ?? false, tzid), ]); } if (event.organizer) { @@ -301,7 +401,7 @@ export function makeVevent( "exdate", { tzid }, "date-time", - formatDateToICal(new Date(ex), false), + formatDateToICal(new Date(ex), false, tzid), ]); }); } @@ -309,19 +409,29 @@ export function makeVevent( return vevent; } -function formatDateToICal(date: Date, allday: Boolean) { - // Format date like: 2025-02-14T11:00:00 (local time) - +function formatDateToICal(date: Date, allday: Boolean, timezone?: string) { const pad = (n: number) => n.toString().padStart(2, "0"); - const year = date.getFullYear(); - const month = pad(date.getMonth() + 1); - const day = pad(date.getDate()); - const hours = pad(date.getHours()); - const minutes = pad(date.getMinutes()); - const seconds = pad(date.getSeconds()); + if (allday) { + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); return `${year}-${month}-${day}`; } + + if (timezone) { + const momentDate = moment.utc(date).tz(timezone); + if (momentDate.isValid()) { + return momentDate.format("YYYY-MM-DDTHH:mm:ss"); + } + } + + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; } @@ -338,12 +448,30 @@ export function combineMasterDateWithFormTime( formatDateTimeInTimezone: (iso: string, tz: string) => string ): { startDate: string; endDate: string } { if (isAllDay) { - const startDate = new Date(masterEvent.start).toISOString().split("T")[0]; - const endDate = masterEvent.end + // Extract date string from master event (which is ISO UTC string) + const startDateStr = new Date(masterEvent.start) + .toISOString() + .split("T")[0]; + const endDateStr = masterEvent.end ? new Date(masterEvent.end).toISOString().split("T")[0] - : startDate; + : startDateStr; - return { startDate, endDate }; + // Parse date string and create Date at UTC midnight to avoid timezone offset issues + const [startYear, startMonth, startDay] = startDateStr + .split("-") + .map(Number); + const [endYear, endMonth, endDay] = endDateStr.split("-").map(Number); + const startDateObj = new Date( + Date.UTC(startYear, startMonth - 1, startDay, 0, 0, 0, 0) + ); + const endDateObj = new Date( + Date.UTC(endYear, endMonth - 1, endDay, 0, 0, 0, 0) + ); + + return { + startDate: startDateObj.toISOString(), + endDate: endDateObj.toISOString(), + }; } // For timed events: combine master's date with form's time @@ -372,8 +500,8 @@ export function combineMasterDateWithFormTime( const combinedEndStr = `${masterEndDatePart}T${formEndTimePart}`; // Parse and convert to ISO - const startDate = new Date(combinedStartStr).toISOString(); - const endDate = new Date(combinedEndStr).toISOString(); + const startDate = convertFormDateTimeToISO(combinedStartStr, timezone); + const endDate = convertFormDateTimeToISO(combinedEndStr, timezone); return { startDate, endDate }; } diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts new file mode 100644 index 0000000..1085026 --- /dev/null +++ b/src/features/Events/formHelpers.ts @@ -0,0 +1,192 @@ +import { TIMEZONES } from "../../utils/timezone-data"; +import { resolveTimezone } from "../../components/Calendar/TimezoneSelector"; +import { CalendarEvent, RepetitionObject } from "./EventsTypes"; +import { userAttendee } from "../User/userDataTypes"; +import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; +import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; + +export interface TimezoneListResult { + zones: string[]; + browserTz: string; + getTimezoneOffset: (tzName: string) => string; +} + +export function createTimezoneList(): TimezoneListResult { + 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 }; +} + +export interface PopulateFormFromEventParams { + event: CalendarEvent; + calendarTimezone?: string; + organizerEmail?: string; + setTitle: (value: string) => void; + setDescription: (value: string) => void; + setLocation: (value: string) => void; + setStart: (value: string) => void; + setEnd: (value: string) => void; + setAllDay: (value: boolean) => void; + setRepetition: (value: RepetitionObject) => void; + setShowRepeat: (value: boolean) => void; + setAttendees: (value: userAttendee[]) => void; + setAlarm: (value: string) => void; + setEventClass: (value: string) => void; + setBusy: (value: string) => void; + setTimezone: (value: string) => void; + setHasVideoConference: (value: boolean) => void; + setMeetingLink: (value: string | null) => void; + setCalendarid?: (value: string) => void; + calendarsList?: Record; + calId?: string; +} + +export function populateFormFromEvent( + params: PopulateFormFromEventParams +): void { + const { + event, + calendarTimezone, + organizerEmail, + setTitle, + setDescription, + setLocation, + setStart, + setEnd, + setAllDay, + setRepetition, + setShowRepeat, + setAttendees, + setAlarm, + setEventClass, + setBusy, + setTimezone, + setHasVideoConference, + setMeetingLink, + setCalendarid, + calendarsList, + calId, + } = params; + + // Basic fields + setTitle(event.title ?? ""); + setDescription(event.description ?? ""); + setLocation(event.location ?? ""); + + // Handle all-day events + const isAllDay = event.allday ?? false; + setAllDay(isAllDay); + + // Get event's timezone for formatting + const eventTimezone = event.timezone + ? resolveTimezone(event.timezone) + : calendarTimezone || + resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); + + // Format dates based on all-day status and timezone + if (event.start) { + if (isAllDay) { + const startDate = new Date(event.start); + setStart(startDate.toISOString().split("T")[0]); + } else { + setStart(formatDateTimeInTimezone(event.start, eventTimezone)); + } + } else { + setStart(""); + } + + if (event.end) { + if (isAllDay) { + const endDate = new Date(event.end); + setEnd(endDate.toISOString().split("T")[0]); + } else { + setEnd(formatDateTimeInTimezone(event.end, eventTimezone)); + } + } else { + setEnd(""); + } + + // Calendar + if (setCalendarid && calId) { + setCalendarid(calId); + } + + // Handle repetition - check both current event and base event (for update modal) + let repetitionSource = event.repetition; + if (calendarsList && calId && event.uid) { + const baseEventId = event.uid.split("/")[0]; + const baseEvent = calendarsList[calId]?.events[baseEventId]; + if (baseEvent?.repetition) { + repetitionSource = baseEvent.repetition; + } + } + + if (repetitionSource && repetitionSource.freq) { + const repetitionData: RepetitionObject = { + freq: repetitionSource.freq, + interval: repetitionSource.interval || 1, + occurrences: repetitionSource.occurrences, + endDate: repetitionSource.endDate, + byday: repetitionSource.byday || null, + }; + setRepetition(repetitionData); + setShowRepeat(true); + } else { + setRepetition({} as RepetitionObject); + setShowRepeat(false); + } + + // Attendees - filter out organizer + const organizerAddress = organizerEmail || event.organizer?.cal_address; + setAttendees( + event.attendee + ? event.attendee.filter( + (a: userAttendee) => a.cal_address !== organizerAddress + ) + : [] + ); + + // Other fields + setAlarm(event.alarm?.trigger ?? ""); + setEventClass(event.class ?? "PUBLIC"); + setBusy(event.transp ?? "OPAQUE"); + setTimezone(eventTimezone); + setHasVideoConference(event.x_openpass_videoconference ? true : false); + setMeetingLink(event.x_openpass_videoconference || null); + + // Update description to include video conference footer if exists + if (event.x_openpass_videoconference && event.description) { + const hasVideoFooter = event.description.includes("Visio:"); + if (!hasVideoFooter) { + setDescription( + addVideoConferenceToDescription( + event.description, + event.x_openpass_videoconference + ) + ); + } else { + setDescription(event.description); + } + } +}